From 8afb46af2e5514c9284030367281793fbb014e4f Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Mon, 9 Mar 2026 11:00:52 +0100 Subject: [PATCH] feat: new plugin system (#489) * feat: new plugin system * Potential fix for code scanning alert no. 312: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update manifest.json * feat(plugins): add SVG icon support with authenticated inline rendering * fix: coderabbit changes and new plugin name & repo * fix: design changes to plugins settings tab * fix(plugins): prevent git arg injection, add repo URL detection * fix: lint errors and deleting plugin error on windows * fix: coderabbit nitpick comments * fix(plugins): harden path traversal and respect enabled state Use realpathSync to canonicalize paths before the plugin asset boundary check, preventing symlink-based traversal bypasses that could escape the plugin directory. PluginTabContent now guards on plugin.enabled before mounting the plugin module, and re-mounts when the enabled state changes so toggling a plugin takes effect without a page reload. PluginIcon safely handles a missing iconFile prop and skips processing non-OK fetch responses instead of attempting to parse error bodies as SVG. Register 'plugins' as a known main tab so the settings router preserves the tab on navigation. * fix(plugins): support concurrent plugin updates Replace single updatingPlugin string state with a Set to allow multiple plugins to update simultaneously. Also disable the update button and show a descriptive tooltip when a plugin has no git remote configured. * fix(plugins): async shutdown and asset/RPC fixes Await stopPluginServer/stopAllPlugins in signal handlers and route handlers so process exit and state transitions wait for clean plugin shutdown instead of racing ahead. Validate asset paths are regular files before streaming to prevent directory traversal returning unexpected content; add a stream error handler to avoid unhandled crashes on read failures. Fix RPC proxy body detection to use the content-length header instead of Object.keys, so falsy but valid JSON payloads (null, false, 0, {}) are forwarded correctly to plugin servers. Track in-flight start operations via a startingPlugins map to prevent duplicate concurrent plugin starts. * refactor(git-panel): simplify setCommitMessage with plain function * fix(plugins): harden input validation and scan reliability - Validate plugin names against [a-zA-Z0-9_-] allowlist in manifest and asset routes to prevent path traversal via URL - Strip embedded credentials (user:pass@) from git remote URLs before exposing them to the client - Skip .tmp-* directories during scan to avoid partial installs from in-progress updates appearing as broken plugins - Deduplicate plugins sharing the same manifest name to prevent ambiguous state - Guard RPC proxy error handler against writing to an already-sent response, preventing uncaught exceptions on aborted requests * fix(git-panel): reset changes view on project switch * refactor: move plugin content to /view folder * fix: resolve type error in MobileNav and PluginTabContent components --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Co-authored-by: Haileyesus --- .gitmodules | 3 + README.md | 14 +- package-lock.json | 2 - server/index.js | 18 + server/routes/plugins.js | 303 ++++++++++++ server/utils/plugin-loader.js | 408 ++++++++++++++++ server/utils/plugin-process-manager.js | 184 +++++++ src/App.tsx | 11 +- src/components/app/MobileNav.tsx | 159 +++++-- src/components/chat/tools/ToolRenderer.tsx | 29 +- src/components/git-panel/view/GitPanel.tsx | 2 + .../git-panel/view/changes/ChangesView.tsx | 3 + .../git-panel/view/changes/CommitComposer.tsx | 17 +- .../main-content/view/MainContent.tsx | 11 + .../subcomponents/MainContentTabSwitcher.tsx | 59 ++- .../view/subcomponents/MainContentTitle.tsx | 14 +- src/components/plugins/view/PluginIcon.tsx | 44 ++ .../plugins/view/PluginSettingsTab.tsx | 450 ++++++++++++++++++ .../plugins/view/PluginTabContent.tsx | 141 ++++++ .../settings/hooks/useSettingsController.ts | 2 +- src/components/settings/types/types.ts | 2 +- src/components/settings/view/Settings.tsx | 7 + .../settings/view/SettingsMainTabs.tsx | 8 +- .../view/subcomponents/SidebarContent.tsx | 44 +- .../view/subcomponents/SidebarHeader.tsx | 24 +- src/contexts/PluginsContext.tsx | 157 ++++++ src/hooks/useProjectsState.ts | 6 +- src/i18n/locales/en/settings.json | 3 +- src/i18n/locales/ja/settings.json | 3 +- src/i18n/locales/ko/settings.json | 3 +- src/i18n/locales/zh-CN/settings.json | 3 +- src/types/app.ts | 2 +- 32 files changed, 2013 insertions(+), 123 deletions(-) create mode 100644 .gitmodules create mode 100644 server/routes/plugins.js create mode 100644 server/utils/plugin-loader.js create mode 100644 server/utils/plugin-process-manager.js create mode 100644 src/components/plugins/view/PluginIcon.tsx create mode 100644 src/components/plugins/view/PluginSettingsTab.tsx create mode 100644 src/components/plugins/view/PluginTabContent.tsx create mode 100644 src/contexts/PluginsContext.tsx 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 ( +