diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ca83e731 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "plugins/starter"] + path = plugins/starter + url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git diff --git a/README.md b/README.md index fc985a5d..4acb9610 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@
CloudCLI UI

Cloud CLI (aka Claude Code UI)

+

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

- -A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. -

- CloudCLI Cloud · Discord · Bug Reports · Contributing + CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

- Join our Discord + CloudCLI Cloud + Join our Discord +

siteboon%2Fclaudecodeui | Trendshift

English · 한국어 · 中文 · 日本語
+--- + ## Screenshots
@@ -59,7 +61,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla - **Git Explorer** - View, stage and commit your changes. You can also switch branches - **Session Management** - Resume conversations, manage multiple sessions, and track history - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini. +- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models) ## Quick Start diff --git a/package-lock.json b/package-lock.json index b1f9203e..9f64eb49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1909,7 +1909,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1926,7 +1925,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/server/index.js b/server/index.js index 30116c4b..e745bf11 100755 --- a/server/index.js +++ b/server/index.js @@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js'; import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; +import pluginsRoutes from './routes/plugins.js'; +import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -389,6 +391,9 @@ app.use('/api/codex', authenticateToken, codexRoutes); // Gemini API Routes (protected) app.use('/api/gemini', authenticateToken, geminiRoutes); +// Plugins API Routes (protected) +app.use('/api/plugins', authenticateToken, pluginsRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); @@ -2532,7 +2537,20 @@ async function startServer() { // Start watching the projects folder for changes await setupProjectsWatcher(); + + // Start server-side plugin processes for enabled plugins + startEnabledPluginServers().catch(err => { + console.error('[Plugins] Error during startup:', err.message); + }); }); + + // Clean up plugin processes on shutdown + const shutdownPlugins = async () => { + await stopAllPlugins(); + process.exit(0); + }; + process.on('SIGTERM', () => void shutdownPlugins()); + process.on('SIGINT', () => void shutdownPlugins()); } catch (error) { console.error('[ERROR] Failed to start server:', error); process.exit(1); diff --git a/server/routes/plugins.js b/server/routes/plugins.js new file mode 100644 index 00000000..ef490c45 --- /dev/null +++ b/server/routes/plugins.js @@ -0,0 +1,303 @@ +import express from 'express'; +import path from 'path'; +import http from 'http'; +import mime from 'mime-types'; +import fs from 'fs'; +import { + scanPlugins, + getPluginsConfig, + getPluginsDir, + savePluginsConfig, + getPluginDir, + resolvePluginAssetPath, + installPluginFromGit, + updatePluginFromGit, + uninstallPlugin, +} from '../utils/plugin-loader.js'; +import { + startPluginServer, + stopPluginServer, + getPluginPort, + isPluginRunning, +} from '../utils/plugin-process-manager.js'; + +const router = express.Router(); + +// GET / — List all installed plugins (includes server running status) +router.get('/', (req, res) => { + try { + const plugins = scanPlugins().map(p => ({ + ...p, + serverRunning: p.server ? isPluginRunning(p.name) : false, + })); + res.json({ plugins }); + } catch (err) { + res.status(500).json({ error: 'Failed to scan plugins', details: err.message }); + } +}); + +// GET /:name/manifest — Get single plugin manifest +router.get('/:name/manifest', (req, res) => { + try { + if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + const plugins = scanPlugins(); + const plugin = plugins.find(p => p.name === req.params.name); + if (!plugin) { + return res.status(404).json({ error: 'Plugin not found' }); + } + res.json(plugin); + } catch (err) { + res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message }); + } +}); + +// GET /:name/assets/* — Serve plugin static files +router.get('/:name/assets/*', (req, res) => { + const pluginName = req.params.name; + if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + const assetPath = req.params[0]; + + if (!assetPath) { + return res.status(400).json({ error: 'No asset path specified' }); + } + + const resolvedPath = resolvePluginAssetPath(pluginName, assetPath); + if (!resolvedPath) { + return res.status(404).json({ error: 'Asset not found' }); + } + + try { + const stat = fs.statSync(resolvedPath); + if (!stat.isFile()) { + return res.status(404).json({ error: 'Asset not found' }); + } + } catch { + return res.status(404).json({ error: 'Asset not found' }); + } + + const contentType = mime.lookup(resolvedPath) || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + const stream = fs.createReadStream(resolvedPath); + stream.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to read asset' }); + } else { + res.end(); + } + }); + stream.pipe(res); +}); + +// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable) +router.put('/:name/enable', async (req, res) => { + try { + const { enabled } = req.body; + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: '"enabled" must be a boolean' }); + } + + const plugins = scanPlugins(); + const plugin = plugins.find(p => p.name === req.params.name); + if (!plugin) { + return res.status(404).json({ error: 'Plugin not found' }); + } + + const config = getPluginsConfig(); + config[req.params.name] = { ...config[req.params.name], enabled }; + savePluginsConfig(config); + + // Start or stop the plugin server as needed + if (plugin.server) { + if (enabled && !isPluginRunning(plugin.name)) { + const pluginDir = getPluginDir(plugin.name); + if (pluginDir) { + try { + await startPluginServer(plugin.name, pluginDir, plugin.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); + } + } + } else if (!enabled && isPluginRunning(plugin.name)) { + await stopPluginServer(plugin.name); + } + } + + res.json({ success: true, name: req.params.name, enabled }); + } catch (err) { + res.status(500).json({ error: 'Failed to update plugin', details: err.message }); + } +}); + +// POST /install — Install plugin from git URL +router.post('/install', async (req, res) => { + try { + const { url } = req.body; + if (!url || typeof url !== 'string') { + return res.status(400).json({ error: '"url" is required and must be a string' }); + } + + // Basic URL validation + if (!url.startsWith('https://') && !url.startsWith('git@')) { + return res.status(400).json({ error: 'URL must start with https:// or git@' }); + } + + const manifest = await installPluginFromGit(url); + + // Auto-start the server if the plugin has one (enabled by default) + if (manifest.server) { + const pluginDir = getPluginDir(manifest.name); + if (pluginDir) { + try { + await startPluginServer(manifest.name, pluginDir, manifest.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message); + } + } + } + + res.json({ success: true, plugin: manifest }); + } catch (err) { + res.status(400).json({ error: 'Failed to install plugin', details: err.message }); + } +}); + +// POST /:name/update — Pull latest from git (restarts server if running) +router.post('/:name/update', async (req, res) => { + try { + const pluginName = req.params.name; + + if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + + const wasRunning = isPluginRunning(pluginName); + if (wasRunning) { + await stopPluginServer(pluginName); + } + + const manifest = await updatePluginFromGit(pluginName); + + // Restart server if it was running before the update + if (wasRunning && manifest.server) { + const pluginDir = getPluginDir(pluginName); + if (pluginDir) { + try { + await startPluginServer(pluginName, pluginDir, manifest.server); + } catch (err) { + console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message); + } + } + } + + res.json({ success: true, plugin: manifest }); + } catch (err) { + res.status(400).json({ error: 'Failed to update plugin', details: err.message }); + } +}); + +// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess +router.all('/:name/rpc/*', async (req, res) => { + const pluginName = req.params.name; + const rpcPath = req.params[0] || ''; + + if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + + let port = getPluginPort(pluginName); + if (!port) { + // Lazily start the plugin server if it exists and is enabled + const plugins = scanPlugins(); + const plugin = plugins.find(p => p.name === pluginName); + if (!plugin || !plugin.server) { + return res.status(503).json({ error: 'Plugin server is not running' }); + } + if (!plugin.enabled) { + return res.status(503).json({ error: 'Plugin is disabled' }); + } + const pluginDir = path.join(getPluginsDir(), plugin.dirName); + try { + port = await startPluginServer(pluginName, pluginDir, plugin.server); + } catch (err) { + return res.status(503).json({ error: 'Plugin server failed to start', details: err.message }); + } + } + + // Inject configured secrets as headers + const config = getPluginsConfig(); + const pluginConfig = config[pluginName] || {}; + const secrets = pluginConfig.secrets || {}; + + const headers = { + 'content-type': req.headers['content-type'] || 'application/json', + }; + + // Add per-plugin secrets as X-Plugin-Secret-* headers + for (const [key, value] of Object.entries(secrets)) { + headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value); + } + + // Reconstruct query string + const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : ''; + + const options = { + hostname: '127.0.0.1', + port, + path: `/${rpcPath}${qs}`, + method: req.method, + headers, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + if (!res.headersSent) { + res.status(502).json({ error: 'Plugin server error', details: err.message }); + } else { + res.end(); + } + }); + + // Forward body (already parsed by express JSON middleware, so re-stringify). + // Check content-length to detect whether a body was actually sent, since + // req.body can be falsy for valid payloads like 0, false, null, or {}. + const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0; + if (hasBody && req.body !== undefined) { + const bodyStr = JSON.stringify(req.body); + proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr)); + proxyReq.write(bodyStr); + } + + proxyReq.end(); +}); + +// DELETE /:name — Uninstall plugin (stops server first) +router.delete('/:name', async (req, res) => { + try { + const pluginName = req.params.name; + + // Validate name format to prevent path traversal + if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + + // Stop server and wait for the process to fully exit before deleting files + if (isPluginRunning(pluginName)) { + await stopPluginServer(pluginName); + } + + await uninstallPlugin(pluginName); + res.json({ success: true, name: pluginName }); + } catch (err) { + res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message }); + } +}); + +export default router; diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js new file mode 100644 index 00000000..e48b7686 --- /dev/null +++ b/server/utils/plugin-loader.js @@ -0,0 +1,408 @@ +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']; + +/** Strip embedded credentials from a repo URL before exposing it to the client. */ +function sanitizeRepoUrl(raw) { + try { + const u = new URL(raw); + u.username = ''; + u.password = ''; + return u.toString().replace(/\/$/, ''); + } catch { + // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment + return raw.replace(/\/\/[^@/]+@/, '//'); + } +} +const ALLOWED_TYPES = ['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, mode: 0o700 }); + } + fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 }); +} + +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(', ')}` }; + } + + // Validate entry is a relative path without traversal + if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) { + return { valid: false, error: 'Entry must be a relative path without ".."' }; + } + + if (manifest.server !== undefined && manifest.server !== null) { + if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) { + return { valid: false, error: 'Server entry must be a relative path string without ".."' }; + } + } + + if (manifest.permissions !== undefined) { + if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) { + return { valid: false, error: 'Permissions must be an array of strings' }; + } + } + + 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; + } + + const seenNames = new Set(); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Skip transient temp directories from in-progress installs + if (entry.name.startsWith('.tmp-')) 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; + } + + // Skip duplicate manifest names + if (seenNames.has(manifest.name)) { + console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`); + continue; + } + seenNames.add(manifest.name); + + // Try to read git remote URL + let repoUrl = null; + try { + const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config'); + if (fs.existsSync(gitConfigPath)) { + const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8'); + const match = gitConfig.match(/url\s*=\s*(.+)/); + if (match) { + repoUrl = match[1].trim().replace(/\.git$/, ''); + // Convert SSH URLs to HTTPS + if (repoUrl.startsWith('git@')) { + repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); + } + // Strip embedded credentials (e.g. https://user:pass@host/...) + repoUrl = sanitizeRepoUrl(repoUrl); + } + } + } catch { /* ignore */ } + + 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 || 'module', + 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, + repoUrl, + }); + } 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 — canonicalize via realpath to defeat symlink bypasses + if (!fs.existsSync(resolved)) return null; + + const realResolved = fs.realpathSync(resolved); + const realPluginDir = fs.realpathSync(pluginDir); + if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) { + return null; + } + + return realResolved; +} + +export function installPluginFromGit(url) { + return new Promise((resolve, reject) => { + if (typeof url !== 'string' || !url.trim()) { + return reject(new Error('Invalid URL: must be a non-empty string')); + } + if (url.startsWith('-')) { + return reject(new Error('Invalid URL: must not start with "-"')); + } + + // 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`)); + } + + // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin + const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`)); + + const cleanupTemp = () => { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {} + }; + + const finalize = (manifest) => { + try { + fs.renameSync(tempDir, targetDir); + } catch (err) { + cleanupTemp(); + return reject(new Error(`Failed to move plugin into place: ${err.message}`)); + } + resolve(manifest); + }; + + const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stderr = ''; + gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); + + gitProcess.on('close', (code) => { + if (code !== 0) { + cleanupTemp(); + return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`)); + } + + // Validate manifest exists + const manifestPath = path.join(tempDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + cleanupTemp(); + return reject(new Error('Cloned repository does not contain a manifest.json')); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + } catch { + cleanupTemp(); + return reject(new Error('manifest.json is not valid JSON')); + } + + const validation = validateManifest(manifest); + if (!validation.valid) { + cleanupTemp(); + return reject(new Error(`Invalid manifest: ${validation.error}`)); + } + + // Reject if another installed plugin already uses this name + const existing = scanPlugins().find(p => p.name === manifest.name); + if (existing) { + cleanupTemp(); + return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`)); + } + + // Run npm install if package.json exists. + // --ignore-scripts prevents postinstall hooks from executing arbitrary code. + const packageJsonPath = path.join(tempDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], { + cwd: tempDir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + npmProcess.on('close', (npmCode) => { + if (npmCode !== 0) { + cleanupTemp(); + return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`)); + } + finalize(manifest); + }); + + npmProcess.on('error', (err) => { + cleanupTemp(); + reject(err); + }); + } else { + finalize(manifest); + } + }); + + gitProcess.on('error', (err) => { + cleanupTemp(); + 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', (npmCode) => { + if (npmCode !== 0) { + return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`)); + } + resolve(manifest); + }); + npmProcess.on('error', (err) => reject(err)); + } else { + resolve(manifest); + } + }); + + gitProcess.on('error', (err) => { + reject(new Error(`Failed to spawn git: ${err.message}`)); + }); + }); +} + +export async function uninstallPlugin(name) { + const pluginDir = getPluginDir(name); + if (!pluginDir) { + throw new Error(`Plugin "${name}" not found`); + } + + // On Windows, file handles may be released slightly after process exit. + // Retry a few times with a short delay before giving up. + const MAX_RETRIES = 5; + const RETRY_DELAY_MS = 500; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + fs.rmSync(pluginDir, { recursive: true, force: true }); + break; + } catch (err) { + if (err.code === 'EBUSY' && attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } else { + throw err; + } + } + } + + // Remove from config + const config = getPluginsConfig(); + delete config[name]; + savePluginsConfig(config); +} diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js new file mode 100644 index 00000000..d5fa493e --- /dev/null +++ b/server/utils/plugin-process-manager.js @@ -0,0 +1,184 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; + +// Map +const runningPlugins = new Map(); +// Map> — in-flight start operations +const startingPlugins = new Map(); + +/** + * Start a plugin's server subprocess. + * The plugin's server entry must print a JSON line with { ready: true, port: } + * to stdout within 10 seconds. + */ +export function startPluginServer(name, pluginDir, serverEntry) { + if (runningPlugins.has(name)) { + return Promise.resolve(runningPlugins.get(name).port); + } + + // Coalesce concurrent starts for the same plugin + if (startingPlugins.has(name)) { + return startingPlugins.get(name); + } + + const startPromise = new Promise((resolve, reject) => { + + const serverPath = path.join(pluginDir, serverEntry); + + // Restricted env — only essentials, no host secrets + const pluginProcess = spawn('node', [serverPath], { + cwd: pluginDir, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + NODE_ENV: process.env.NODE_ENV || 'production', + PLUGIN_NAME: name, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let resolved = false; + let stdout = ''; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + pluginProcess.kill(); + reject(new Error('Plugin server did not report ready within 10 seconds')); + } + }, 10000); + + pluginProcess.stdout.on('data', (data) => { + if (resolved) return; + stdout += data.toString(); + + // Look for the JSON ready line + const lines = stdout.split('\n'); + for (const line of lines) { + try { + const msg = JSON.parse(line.trim()); + if (msg.ready && typeof msg.port === 'number') { + clearTimeout(timeout); + resolved = true; + runningPlugins.set(name, { process: pluginProcess, port: msg.port }); + + pluginProcess.on('exit', () => { + runningPlugins.delete(name); + }); + + console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`); + resolve(msg.port); + } + } catch { + // Not JSON yet, keep buffering + } + } + }); + + pluginProcess.stderr.on('data', (data) => { + console.warn(`[Plugin:${name}] ${data.toString().trim()}`); + }); + + pluginProcess.on('error', (err) => { + clearTimeout(timeout); + if (!resolved) { + resolved = true; + reject(new Error(`Failed to start plugin server: ${err.message}`)); + } + }); + + pluginProcess.on('exit', (code) => { + clearTimeout(timeout); + runningPlugins.delete(name); + if (!resolved) { + resolved = true; + reject(new Error(`Plugin server exited with code ${code} before reporting ready`)); + } + }); + }).finally(() => { + startingPlugins.delete(name); + }); + + startingPlugins.set(name, startPromise); + return startPromise; +} + +/** + * Stop a plugin's server subprocess. + * Returns a Promise that resolves when the process has fully exited. + */ +export function stopPluginServer(name) { + const entry = runningPlugins.get(name); + if (!entry) return Promise.resolve(); + + return new Promise((resolve) => { + const cleanup = () => { + clearTimeout(forceKillTimer); + runningPlugins.delete(name); + resolve(); + }; + + entry.process.once('exit', cleanup); + + entry.process.kill('SIGTERM'); + + // Force kill after 5 seconds if still running + const forceKillTimer = setTimeout(() => { + if (runningPlugins.has(name)) { + entry.process.kill('SIGKILL'); + cleanup(); + } + }, 5000); + + console.log(`[Plugins] Server stopped for "${name}"`); + }); +} + +/** + * Get the port a running plugin server is listening on. + */ +export function getPluginPort(name) { + return runningPlugins.get(name)?.port ?? null; +} + +/** + * Check if a plugin's server is running. + */ +export function isPluginRunning(name) { + return runningPlugins.has(name); +} + +/** + * Stop all running plugin servers (called on host shutdown). + */ +export function stopAllPlugins() { + const stops = []; + for (const [name] of runningPlugins) { + stops.push(stopPluginServer(name)); + } + return Promise.all(stops); +} + +/** + * Start servers for all enabled plugins that have a server entry. + * Called once on host server boot. + */ +export async function startEnabledPluginServers() { + const plugins = scanPlugins(); + const config = getPluginsConfig(); + + for (const plugin of plugins) { + if (!plugin.server) continue; + if (config[plugin.name]?.enabled === false) continue; + + const pluginDir = getPluginDir(plugin.name); + if (!pluginDir) continue; + + try { + await startPluginServer(plugin.name, pluginDir, plugin.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 564ee1a3..bcbda826 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth'; import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { WebSocketProvider } from './contexts/WebSocketContext'; +import { PluginsProvider } from './contexts/PluginsContext'; import AppContent from './components/app/AppContent'; import i18n from './i18n/config.js'; @@ -14,8 +15,9 @@ export default function App() { - - + + + @@ -24,8 +26,9 @@ export default function App() { - - + + + diff --git a/src/components/app/MobileNav.tsx b/src/components/app/MobileNav.tsx index 0ca82bc9..ea49f009 100644 --- a/src/components/app/MobileNav.tsx +++ b/src/components/app/MobileNav.tsx @@ -1,8 +1,35 @@ -import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react'; -import { Dispatch, SetStateAction } from 'react'; +import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'; +import { + MessageSquare, + Folder, + Terminal, + GitBranch, + ClipboardCheck, + Ellipsis, + Puzzle, + Box, + Database, + Globe, + Wrench, + Zap, + BarChart3, + type LucideIcon, +} from 'lucide-react'; import { useTasksSettings } from '../../contexts/TasksSettingsContext'; +import { usePlugins } from '../../contexts/PluginsContext'; import { AppTab } from '../../types/app'; +const PLUGIN_ICON_MAP: Record = { + Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch, +}; + +type CoreTabId = Exclude; +type CoreNavItem = { + id: CoreTabId; + icon: LucideIcon; + label: string; +}; + type MobileNavProps = { activeTab: AppTab; setActiveTab: Dispatch>; @@ -12,39 +39,43 @@ type MobileNavProps = { export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); + const { plugins } = usePlugins(); + const [moreOpen, setMoreOpen] = useState(false); + const moreRef = useRef(null); - const navItems = [ - { - id: 'chat', - icon: MessageSquare, - label: 'Chat', - onClick: () => setActiveTab('chat') - }, - { - id: 'shell', - icon: Terminal, - label: 'Shell', - onClick: () => setActiveTab('shell') - }, - { - id: 'files', - icon: Folder, - label: 'Files', - onClick: () => setActiveTab('files') - }, - { - id: 'git', - icon: GitBranch, - label: 'Git', - onClick: () => setActiveTab('git') - }, - ...(shouldShowTasksTab ? [{ - id: 'tasks', - icon: ClipboardCheck, - label: 'Tasks', - onClick: () => setActiveTab('tasks') - }] : []) + const enabledPlugins = plugins.filter((p) => p.enabled); + const hasPlugins = enabledPlugins.length > 0; + const isPluginActive = activeTab.startsWith('plugin:'); + + // Close the menu on outside tap + useEffect(() => { + if (!moreOpen) return; + const handleTap = (e: PointerEvent) => { + const target = e.target; + if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) { + setMoreOpen(false); + } + }; + document.addEventListener('pointerdown', handleTap); + return () => document.removeEventListener('pointerdown', handleTap); + }, [moreOpen]); + + // Close menu when a plugin tab is selected + const selectPlugin = (name: string) => { + const pluginTab = `plugin:${name}` as AppTab; + setActiveTab(pluginTab); + setMoreOpen(false); + }; + + const baseCoreItems: CoreNavItem[] = [ + { id: 'chat', icon: MessageSquare, label: 'Chat' }, + { id: 'shell', icon: Terminal, label: 'Shell' }, + { id: 'files', icon: Folder, label: 'Files' }, + { id: 'git', icon: GitBranch, label: 'Git' }, ]; + const coreItems: CoreNavItem[] = shouldShowTasksTab + ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] + : baseCoreItems; return (
- {navItems.map((item) => { + {coreItems.map((item) => { const Icon = item.icon; const isActive = activeTab === item.id; return ( ); })} + + {/* "More" button — only shown when there are enabled plugins */} + {hasPlugins && ( +
+ + + {/* Popover menu */} + {moreOpen && ( +
+ {enabledPlugins.map((p) => { + const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle; + const isActive = activeTab === `plugin:${p.name}`; + + return ( + + ); + })} +
+ )} +
+ )}
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 1f1159f5..978723d7 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -61,20 +61,6 @@ export const ToolRenderer: React.FC = memo(({ isSubagentContainer, subagentState }) => { - // Route subagent containers to dedicated component - if (isSubagentContainer && subagentState) { - if (mode === 'result') { - return null; - } - return ( - - ); - } - const config = getToolConfig(toolName); const displayConfig: any = mode === 'input' ? config.input : config.result; @@ -94,7 +80,20 @@ export const ToolRenderer: React.FC = memo(({ } }, [displayConfig, parsedData, onFileOpen]); - // Keep hooks above this guard so hook call order stays stable across renders. + // Route subagent containers to dedicated component (after hooks to keep call order stable) + if (isSubagentContainer && subagentState) { + if (mode === 'result') { + return null; + } + return ( + + ); + } + if (!displayConfig) return null; if (displayConfig.type === 'one-line') { diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index 7f071c0e..c08c2840 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -107,7 +107,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen {activeView === 'changes' && ( (); + type CommitComposerProps = { isMobile: boolean; + projectPath: string; selectedFileCount: number; isHidden: boolean; onCommit: (message: string) => Promise; @@ -14,13 +18,24 @@ type CommitComposerProps = { export default function CommitComposer({ isMobile, + projectPath, selectedFileCount, isHidden, onCommit, onGenerateMessage, onRequestConfirmation, }: CommitComposerProps) { - const [commitMessage, setCommitMessage] = useState(''); + const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? ''); + + const setCommitMessage = (msg: string) => { + setCommitMessageRaw(msg); + if (msg) { + commitMessageCache.set(projectPath, msg); + } else { + commitMessageCache.delete(projectPath); + } + }; + const [isCommitting, setIsCommitting] = useState(false); const [isGeneratingMessage, setIsGeneratingMessage] = useState(false); const [isCollapsed, setIsCollapsed] = useState(isMobile); diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 786f807e..5d6ef053 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface'; import FileTree from '../../file-tree/view/FileTree'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import GitPanel from '../../git-panel/view/GitPanel'; +import PluginTabContent from '../../plugins/view/PluginTabContent'; import type { MainContentProps } from '../types/types'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; @@ -158,6 +159,16 @@ function MainContent({ {shouldShowTasksTab && }
+ + {activeTab.startsWith('plugin:') && ( +
+ +
+ )}
p.enabled) + .map((p) => ({ + kind: 'plugin', + id: `plugin:${p.name}` as AppTab, + label: p.displayName, + pluginName: p.name, + iconFile: p.icon, + })); + + const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs]; return (
{tabs.map((tab) => { - const Icon = tab.icon; const isActive = tab.id === activeTab; + const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label; return ( - + ); diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx index 698796f3..6cc88ba1 100644 --- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx +++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { AppTab, Project, ProjectSession } from '../../../../types/app'; +import { usePlugins } from '../../../../contexts/PluginsContext'; type MainContentTitleProps = { activeTab: AppTab; @@ -9,7 +10,11 @@ type MainContentTitleProps = { shouldShowTasksTab: boolean; }; -function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) { +function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) { + if (activeTab.startsWith('plugin:') && pluginDisplayName) { + return pluginDisplayName; + } + if (activeTab === 'files') { return t('mainContent.projectFiles'); } @@ -40,6 +45,11 @@ export default function MainContentTitle({ shouldShowTasksTab, }: MainContentTitleProps) { const { t } = useTranslation(); + const { plugins } = usePlugins(); + + const pluginDisplayName = activeTab.startsWith('plugin:') + ? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName + : undefined; const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession); const showChatNewSession = activeTab === 'chat' && !selectedSession; @@ -68,7 +78,7 @@ export default function MainContentTitle({ ) : (

- {getTabTitle(activeTab, shouldShowTasksTab, t)} + {getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}

{selectedProject.displayName}
diff --git a/src/components/plugins/view/PluginIcon.tsx b/src/components/plugins/view/PluginIcon.tsx new file mode 100644 index 00000000..fd59dbbc --- /dev/null +++ b/src/components/plugins/view/PluginIcon.tsx @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; + +type Props = { + pluginName: string; + iconFile: string; + className?: string; +}; + +// Module-level cache so repeated renders don't re-fetch +const svgCache = new Map(); + +export default function PluginIcon({ pluginName, iconFile, className }: Props) { + const url = iconFile + ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` + : ''; + const [svg, setSvg] = useState(url ? (svgCache.get(url) ?? null) : null); + + useEffect(() => { + if (!url || svgCache.has(url)) return; + authenticatedFetch(url) + .then((r) => { + if (!r.ok) return; + return r.text(); + }) + .then((text) => { + if (text && text.trimStart().startsWith(' {}); + }, [url]); + + if (!svg) return ; + + return ( + + ); +} diff --git a/src/components/plugins/view/PluginSettingsTab.tsx b/src/components/plugins/view/PluginSettingsTab.tsx new file mode 100644 index 00000000..b65edc41 --- /dev/null +++ b/src/components/plugins/view/PluginSettingsTab.tsx @@ -0,0 +1,450 @@ +import { useState } from 'react'; +import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; +import { usePlugins } from '../../../contexts/PluginsContext'; +import type { Plugin } from '../../../contexts/PluginsContext'; +import PluginIcon from './PluginIcon'; + +const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; + +/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ +function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { + return ( +