From b4169887ab851b6e2262c2b973faa62261af8962 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Thu, 5 Mar 2026 22:51:27 +0000 Subject: [PATCH] feat: new plugin system --- examples/plugins/hello-world/README.md | 227 ++++++++++++ examples/plugins/hello-world/index.html | 114 ++++++ examples/plugins/hello-world/index.js | 272 ++++++++++++++ examples/plugins/hello-world/manifest.json | 13 + examples/plugins/hello-world/server.js | 105 ++++++ server/index.js | 18 + server/routes/plugins.js | 273 ++++++++++++++ server/utils/plugin-loader.js | 288 +++++++++++++++ server/utils/plugin-process-manager.js | 162 ++++++++ src/App.tsx | 11 +- src/components/MobileNav.jsx | 133 +++++-- .../main-content/view/MainContent.tsx | 11 + .../subcomponents/MainContentTabSwitcher.tsx | 68 +++- .../view/subcomponents/MainContentTitle.tsx | 14 +- src/components/plugins/PluginSettingsTab.tsx | 347 ++++++++++++++++++ src/components/plugins/PluginTabContent.tsx | 126 +++++++ src/components/settings/types/types.ts | 2 +- src/components/settings/view/Settings.tsx | 7 + .../settings/view/SettingsMainTabs.tsx | 8 +- src/contexts/PluginsContext.tsx | 130 +++++++ src/hooks/useProjectsState.ts | 6 +- src/types/app.ts | 2 +- 22 files changed, 2276 insertions(+), 61 deletions(-) create mode 100644 examples/plugins/hello-world/README.md create mode 100644 examples/plugins/hello-world/index.html create mode 100644 examples/plugins/hello-world/index.js create mode 100644 examples/plugins/hello-world/manifest.json create mode 100644 examples/plugins/hello-world/server.js 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/PluginSettingsTab.tsx create mode 100644 src/components/plugins/PluginTabContent.tsx create mode 100644 src/contexts/PluginsContext.tsx diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md new file mode 100644 index 0000000..1897ab5 --- /dev/null +++ b/examples/plugins/hello-world/README.md @@ -0,0 +1,227 @@ +# Project Stats — example plugin + +Scans the currently selected project and shows file counts, lines of code, a file-type breakdown chart, largest files, and recently modified files. + +This is the example plugin that ships with Claude Code UI. It demonstrates all three plugin capabilities in a way that's immediately useful. See the sections below for the full plugin authoring guide. + +## How plugins work + +A plugin's UI is a plain ES module loaded directly into the host app — no iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The module calls the server through an `api.rpc()` helper (the host proxies the calls using its own auth token). + +``` +┌─────────────────────────────────────────────────────────┐ +│ Host server │ +│ │ +│ Lifecycle: │ +│ git clone / git pull Install & update │ +│ npm install Dependency setup │ +│ │ +│ Runtime: │ +│ GET /api/plugins List plugins │ +│ GET /api/plugins/:name/assets/* Serve static files │ +│ ALL /api/plugins/:name/rpc/* Proxy → subprocess │ +│ PUT /api/plugins/:name/enable Toggle + start/stop │ +│ DELETE /api/plugins/:name Uninstall + stop │ +│ │ +│ Plugin subprocess (server.js): │ +│ Runs as a child process with restricted env │ +│ Listens on random local port │ +│ Receives secrets via X-Plugin-Secret-* headers │ +└───────────┬─────────────────────────┬───────────────────┘ + │ serves static files │ proxies RPC +┌───────────▼─────────────────────────▼───────────────────┐ +│ Frontend (browser) │ +│ │ +│ Plugin module (index.js) │ +│ import(url) → mount(container, api) │ +│ api.context — theme / project / session │ +│ api.onContextChange — subscribe to changes │ +│ api.rpc(method, path, body) → Promise │ +└─────────────────────────────────────────────────────────┘ +``` + +## Plugin structure + +``` +my-plugin/ + manifest.json # Required — plugin metadata + index.js # Frontend entry point (ES module, mount/unmount exports) + server.js # Optional — backend entry point (runs as subprocess) + package.json # Optional — npm dependencies for server.js +``` + +All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. + +## manifest.json + +```jsonc +{ + "name": "hello-world", // Unique id — alphanumeric, hyphens, underscores only + "displayName": "Hello World", // Shown in the UI + "version": "1.0.0", + "description": "Short description shown in settings.", + "author": "Your Name", + "icon": "Puzzle", // Lucide icon name (see available icons below) + "type": "module", // "module" (default) or "iframe" (legacy) + "slot": "tab", // Where the plugin appears — only "tab" is supported today + "entry": "index.js", // Frontend entry file, relative to plugin directory + "server": "server.js", // Optional — backend entry file, runs as Node.js subprocess + "permissions": [] // Reserved for future use +} +``` + +### Available icons + +`Puzzle` (default), `Box`, `Database`, `Globe`, `Terminal`, `Wrench`, `Zap`, `BarChart3`, `Folder`, `MessageSquare`, `GitBranch` + +## Installation + +**Manual:** Copy your plugin folder into `~/.claude-code-ui/plugins/`. + +**From git:** In Settings > Plugins, paste a git URL and click Install. The repo is cloned into the plugins directory. + +--- + +## Frontend — Module API + +The host dynamically imports your entry file and calls `mount(container, api)`. When the plugin tab is closed or the plugin is disabled, `unmount(container)` is called. + +```js +// index.js + +export function mount(container, api) { + // api.context — current snapshot: { theme, project, session } + // api.onContextChange(cb) — subscribe, returns an unsubscribe function + // api.rpc(method, path, body?) — call the plugin's server subprocess + + container.innerHTML = '

Hello!

'; + + const unsub = api.onContextChange((ctx) => { + container.style.background = ctx.theme === 'dark' ? '#111' : '#fff'; + }); + + container._cleanup = unsub; +} + +export function unmount(container) { + if (typeof container._cleanup === 'function') container._cleanup(); + container.innerHTML = ''; +} +``` + +### Context object + +```js +api.context // always up to date +// { +// theme: "dark" | "light", +// project: { name: string, path: string } | null, +// session: { id: string, title: string } | null, +// } +``` + +### RPC helper + +```js +// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch +const data = await api.rpc('GET', '/hello'); + +// With a JSON body +const result = await api.rpc('POST', '/echo', { greeting: 'hi' }); +``` + +--- + +## Backend — Server subprocess + +Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle: + +### How it works + +1. When the plugin is enabled, the host spawns `node server.js` as a child process +2. The subprocess **must** print a JSON line to stdout: `{"ready": true, "port": 12345}` +3. The host records the port and proxies requests from `/api/plugins/:name/rpc/*` to it +4. When the plugin is disabled or uninstalled, the host sends SIGTERM to the process + +### Restricted environment + +The subprocess runs with a **minimal env** — only `PATH`, `HOME`, `NODE_ENV`, and `PLUGIN_NAME`. It does **not** inherit the host's API keys, database URLs, or other secrets from `process.env`. + +### Secrets + +Per-plugin secrets are stored in `~/.claude-code-ui/plugins.json` and injected as HTTP headers on every proxied request: + +```json +{ + "hello-world": { + "enabled": true, + "secrets": { + "apiKey": "sk-live-..." + } + } +} +``` + +The plugin's server receives these as `x-plugin-secret-apikey` headers — they are per-call, never stored in the subprocess env. + +### Example server.js + +```js +const http = require('http'); + +const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + + // Read host-injected secrets + const apiKey = req.headers['x-plugin-secret-apikey']; + + if (req.method === 'GET' && req.url === '/hello') { + res.end(JSON.stringify({ message: 'Hello!', hasApiKey: Boolean(apiKey) })); + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +// Listen on a random port and signal readiness +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + console.log(JSON.stringify({ ready: true, port })); +}); +``` + +--- + +## Frontend — Mobile + +On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space. + +## Security + +### Frontend isolation + +Plugin modules run in the same JS context as the host app but have no access to auth tokens or internal state — only the `api` object passed to `mount`. They cannot make authenticated API calls directly; all server communication goes through `api.rpc()`, which the host proxies. + +### Server subprocess isolation + +The subprocess runs as a separate OS process with: + +- **Restricted env** — no host secrets inherited; only `PATH`, `HOME`, `NODE_ENV`, `PLUGIN_NAME` +- **Per-call secrets** — injected as HTTP headers by the host proxy, never stored in process env +- **Process boundary** — a crash in the plugin cannot crash the host +- **Auth stripping** — the host removes `authorization` and `cookie` headers before proxying + +The subprocess runs as the same OS user, so it has the same filesystem/network access. This matches the trust model of VS Code extensions, Grafana backend plugins, and Terraform providers — the user explicitly installs the plugin. + +### Install-time protections + +npm `postinstall` scripts are blocked during installation (`--ignore-scripts`). Plugins that need npm packages should ship pre-built or use packages that work without postinstall hooks. + +## Try it + +```bash +cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/ +``` + +Then open Settings > Plugins — "Project Stats" should appear. Enable it, select a project, and open its tab to see the stats. diff --git a/examples/plugins/hello-world/index.html b/examples/plugins/hello-world/index.html new file mode 100644 index 0000000..ea8bf1c --- /dev/null +++ b/examples/plugins/hello-world/index.html @@ -0,0 +1,114 @@ + + + + + + Hello World Plugin + + + +

Hello from a plugin!

+

This tab is rendered inside a sandboxed iframe.

+ +
+
Theme
+
Project
+
Session
+
+ +

Server RPC

+

This calls the plugin's own Node.js server subprocess via the postMessage RPC bridge.

+ + +
Click a button to make an RPC call...
+ + + + diff --git a/examples/plugins/hello-world/index.js b/examples/plugins/hello-world/index.js new file mode 100644 index 0000000..48a9c4b --- /dev/null +++ b/examples/plugins/hello-world/index.js @@ -0,0 +1,272 @@ +/** + * Project Stats plugin — module entry point. + * + * api shape: + * api.context — current PluginContext snapshot + * api.onContextChange(cb) → unsubscribe — called on theme/project/session changes + * api.rpc(method, path, body?) → Promise — proxied to this plugin's server subprocess + */ + +const PALETTE = [ + '#6366f1','#22d3ee','#f59e0b','#10b981', + '#f43f5e','#a78bfa','#fb923c','#34d399', + '#60a5fa','#e879f9','#facc15','#4ade80', +]; + +function ensureAssets() { + if (document.getElementById('ps-font')) return; + const link = document.createElement('link'); + link.id = 'ps-font'; + link.rel = 'stylesheet'; + link.href = 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'; + document.head.appendChild(link); + + const s = document.createElement('style'); + s.id = 'ps-styles'; + s.textContent = ` + @keyframes ps-grow { from { width: 0 } } + @keyframes ps-fadeup { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:translateY(0) } } + @keyframes ps-pulse { 0%,100% { opacity:.3 } 50% { opacity:.6 } } + .ps-bar { animation: ps-grow 0.75s cubic-bezier(.16,1,.3,1) both } + .ps-up { animation: ps-fadeup 0.4s ease both } + .ps-skel { animation: ps-pulse 1.6s ease infinite } + `; + document.head.appendChild(s); +} + +const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace"; + +function fmt(b) { + if (b < 1024) return `${b}B`; + if (b < 1048576) return `${(b / 1024).toFixed(1)}KB`; + return `${(b / 1048576).toFixed(1)}MB`; +} + +function ago(ms) { + const s = Math.floor((Date.now() - ms) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +function countUp(el, target, formatFn, duration = 900) { + const start = performance.now(); + function tick(now) { + const p = Math.min((now - start) / duration, 1); + const ease = 1 - (1 - p) ** 3; + el.textContent = formatFn(Math.round(target * ease)); + if (p < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); +} + +function v(dark) { + return dark ? { + bg: '#08080f', + surface: '#0e0e1a', + border: '#1a1a2c', + text: '#e2e0f0', + muted: '#52507a', + accent: '#fbbf24', + dim: 'rgba(251,191,36,0.1)', + } : { + bg: '#fafaf9', + surface: '#ffffff', + border: '#e8e6f0', + text: '#0f0e1a', + muted: '#9490b0', + accent: '#d97706', + dim: 'rgba(217,119,6,0.08)', + }; +} + +function skeletonRows(c, widths) { + return widths.map((w, i) => ` +
`).join(''); +} + +export function mount(container, api) { + ensureAssets(); + let cache = null; + + const root = document.createElement('div'); + Object.assign(root.style, { + height: '100%', overflowY: 'auto', boxSizing: 'border-box', + padding: '24px', fontFamily: MONO, + }); + container.appendChild(root); + + function render(ctx, stats) { + const c = v(ctx.theme === 'dark'); + root.style.background = c.bg; + root.style.color = c.text; + + if (!ctx.project) { + root.innerHTML = ` +
+
~/.projects/
+└── (none selected)
+
select a project
+
`; + return; + } + + if (!stats) { + root.innerHTML = ` +
+
${ctx.project.name}
+
${ctx.project.path}
+
+
+ ${[0, 1, 2].map(i => ` +
+ ${skeletonRows(c, [65])} + ${skeletonRows(c, [40])} +
`).join('')} +
+
+ ${[75, 55, 38, 22, 14].map((w, i) => ` +
+
+
+
`).join('')} +
`; + return; + } + + const maxCount = stats.byExtension[0]?.[1] || 1; + + root.innerHTML = ` +
+
+
+ ${ctx.project.name} +
+
${ctx.project.path}
+
+ +
+ +
+ ${[ + ['files', stats.totalFiles, n => n.toLocaleString()], + ['lines', stats.totalLines, n => n.toLocaleString()], + ['total size', stats.totalSize, fmt], + ].map(([label, val, fmt], i) => ` +
+
0
+
${label}
+
`).join('')} +
+ +
+
file types
+ ${stats.byExtension.map(([ext, count], i) => ` +
+
${ext}
+
+
+
+
${count}
+
`).join('')} +
+ +
+ ${[ + ['largest files', stats.largest.map(f => [f.name, fmt(f.size)])], + ['recently modified', stats.recent.map(f => [f.name, ago(f.mtime)])], + ].map(([title, rows], ci) => ` +
+
${title}
+ ${rows.map(([name, val], ri) => ` +
+
${name}
+
${val}
+
`).join('')} +
`).join('')} +
+ `; + + // Count-up animations for metric cards + [ + [0, stats.totalFiles, n => n.toLocaleString()], + [1, stats.totalLines, n => n.toLocaleString()], + [2, stats.totalSize, fmt], + ].forEach(([i, val, formatFn]) => { + const el = root.querySelector(`#ps-m-${i}`); + if (el) countUp(el, val, formatFn); + }); + + root.querySelector('#ps-refresh')?.addEventListener('click', () => { + cache = null; + load(api.context); + }); + } + + async function load(ctx) { + if (!ctx.project) { cache = null; render(ctx, null); return; } + render(ctx, null); + try { + const stats = await api.rpc('GET', `stats?path=${encodeURIComponent(ctx.project.path)}`); + cache = { projectPath: ctx.project.path, stats }; + render(ctx, stats); + } catch (err) { + const c = v(ctx.theme === 'dark'); + root.style.background = c.bg; + root.innerHTML = ` +
+ ✗ ${err.message} +
`; + } + } + + load(api.context); + + const unsubscribe = api.onContextChange(ctx => { + if (ctx.project?.path === cache?.projectPath) render(ctx, cache?.stats ?? null); + else load(ctx); + }); + + container._psUnsubscribe = unsubscribe; +} + +export function unmount(container) { + if (typeof container._psUnsubscribe === 'function') { + container._psUnsubscribe(); + delete container._psUnsubscribe; + } + container.innerHTML = ''; +} diff --git a/examples/plugins/hello-world/manifest.json b/examples/plugins/hello-world/manifest.json new file mode 100644 index 0000000..aa62231 --- /dev/null +++ b/examples/plugins/hello-world/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "hello-world", + "displayName": "Project Stats", + "version": "2.0.0", + "description": "Scans the current project and shows file counts, lines of code, file-type breakdown, largest files, and recently modified files.", + "author": "Claude Code UI", + "icon": "icon.svg", + "type": "module", + "slot": "tab", + "entry": "index.js", + "server": "server.js", + "permissions": [] +} diff --git a/examples/plugins/hello-world/server.js b/examples/plugins/hello-world/server.js new file mode 100644 index 0000000..d800fbf --- /dev/null +++ b/examples/plugins/hello-world/server.js @@ -0,0 +1,105 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const TEXT_EXTS = new Set([ + '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.astro', + '.css', '.scss', '.sass', '.less', + '.html', '.htm', '.xml', '.svg', + '.json', '.yaml', '.yml', '.toml', '.ini', + '.md', '.mdx', '.txt', '.rst', + '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', + '.sh', '.bash', '.zsh', '.fish', + '.sql', '.graphql', '.gql', +]); + +const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', + 'coverage', '.cache', '__pycache__', '.venv', 'venv', + 'target', 'vendor', '.turbo', 'out', '.output', 'tmp', +]); + +function scan(dir, max = 5000) { + const files = []; + (function walk(d, depth) { + if (depth > 6 || files.length >= max) return; + let entries; + try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (files.length >= max) break; + if (e.name.startsWith('.') && e.name !== '.env') continue; + const full = path.join(d, e.name); + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) walk(full, depth + 1); + } else if (e.isFile()) { + try { + const stat = fs.statSync(full); + files.push({ + full, + rel: path.relative(dir, full), + ext: path.extname(e.name).toLowerCase() || '(none)', + size: stat.size, + mtime: stat.mtimeMs, + }); + } catch { /* skip unreadable */ } + } + } + })(dir, 0); + return files; +} + +function countLines(full, size) { + if (size > 256 * 1024) return 0; // skip large files + try { return (fs.readFileSync(full, 'utf-8').match(/\n/g) || []).length + 1; } + catch { return 0; } +} + +function getStats(projectPath) { + if (!projectPath || !path.isAbsolute(projectPath)) throw new Error('Invalid path'); + if (!fs.existsSync(projectPath)) throw new Error('Path does not exist'); + + const files = scan(projectPath); + const byExt = {}; + let totalLines = 0; + let totalSize = 0; + + for (const f of files) { + byExt[f.ext] = (byExt[f.ext] || 0) + 1; + totalSize += f.size; + if (TEXT_EXTS.has(f.ext)) totalLines += countLines(f.full, f.size); + } + + return { + totalFiles: files.length, + totalLines, + totalSize, + byExtension: Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 12), + largest: [...files].sort((a, b) => b.size - a.size).slice(0, 6).map(f => ({ name: f.rel, size: f.size })), + recent: [...files].sort((a, b) => b.mtime - a.mtime).slice(0, 6).map(f => ({ name: f.rel, mtime: f.mtime })), + }; +} + +const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'GET' && req.url.startsWith('/stats')) { + try { + const { searchParams } = new URL(req.url, 'http://localhost'); + const stats = getStats(searchParams.get('path')); + res.end(JSON.stringify(stats)); + } catch (err) { + res.writeHead(400); + res.end(JSON.stringify({ error: err.message })); + } + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + // Signal readiness to the host — this JSON line is required + console.log(JSON.stringify({ ready: true, port })); +}); diff --git a/server/index.js b/server/index.js index 8f25fc2..c112266 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); @@ -2487,7 +2492,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 = () => { + stopAllPlugins(); + process.exit(0); + }; + process.on('SIGTERM', shutdownPlugins); + process.on('SIGINT', 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 0000000..c9ecb34 --- /dev/null +++ b/server/routes/plugins.js @@ -0,0 +1,273 @@ +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 { + 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; + 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' }); + } + + const contentType = mime.lookup(resolvedPath) || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + fs.createReadStream(resolvedPath).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)) { + 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) { + 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) => { + res.status(502).json({ error: 'Plugin server error', details: err.message }); + }); + + // Forward body (already parsed by express JSON middleware, so re-stringify) + if (req.body && Object.keys(req.body).length > 0) { + 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', (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 if running + if (isPluginRunning(pluginName)) { + stopPluginServer(pluginName); + } + + 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 0000000..a13a7ff --- /dev/null +++ b/server/utils/plugin-loader.js @@ -0,0 +1,288 @@ +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.join(pluginsDir, repoName); + + 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); +} diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js new file mode 100644 index 0000000..11f8fd2 --- /dev/null +++ b/server/utils/plugin-process-manager.js @@ -0,0 +1,162 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; + +// Map +const runningPlugins = 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) { + return new Promise((resolve, reject) => { + if (runningPlugins.has(name)) { + return resolve(runningPlugins.get(name).port); + } + + 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`)); + } + }); + }); +} + +/** + * Stop a plugin's server subprocess. + */ +export function stopPluginServer(name) { + const entry = runningPlugins.get(name); + if (!entry) return; + + entry.process.kill('SIGTERM'); + + // Force kill after 5 seconds if still running + const forceKillTimer = setTimeout(() => { + if (runningPlugins.has(name)) { + entry.process.kill('SIGKILL'); + runningPlugins.delete(name); + } + }, 5000); + + entry.process.on('exit', () => { + clearTimeout(forceKillTimer); + }); + + 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() { + for (const [name] of runningPlugins) { + stopPluginServer(name); + } +} + +/** + * 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 8593236..bd27d10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { WebSocketProvider } from './contexts/WebSocketContext'; +import { PluginsProvider } from './contexts/PluginsContext'; import ProtectedRoute from './components/ProtectedRoute'; import AppContent from './components/app/AppContent'; import i18n from './i18n/config.js'; @@ -15,8 +16,9 @@ export default function App() { - - + + + @@ -25,8 +27,9 @@ export default function App() { - - + + + diff --git a/src/components/MobileNav.jsx b/src/components/MobileNav.jsx index 6c26579..acdea88 100644 --- a/src/components/MobileNav.jsx +++ b/src/components/MobileNav.jsx @@ -1,43 +1,48 @@ -import React from 'react'; -import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react'; +import React, { useState, useRef, useEffect } from 'react'; +import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTaskMaster } from '../contexts/TaskMasterContext'; +import { usePlugins } from '../contexts/PluginsContext'; + +const PLUGIN_ICON_MAP = { + Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch, +}; function MobileNav({ activeTab, setActiveTab, isInputFocused }) { 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) => { + if (moreRef.current && !moreRef.current.contains(e.target)) { + setMoreOpen(false); + } + }; + document.addEventListener('pointerdown', handleTap); + return () => document.removeEventListener('pointerdown', handleTap); + }, [moreOpen]); + + // Close menu when a plugin tab is selected + const selectPlugin = (name) => { + setActiveTab(`plugin:${name}`); + setMoreOpen(false); + }; + + const coreItems = [ + { id: 'chat', icon: MessageSquare, label: 'Chat' }, + { id: 'shell', icon: Terminal, label: 'Shell' }, + { id: 'files', icon: Folder, label: 'Files' }, + { id: 'git', icon: GitBranch, label: 'Git' }, + ...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []), ]; return ( @@ -48,17 +53,17 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) { >
- {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/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 3a03022..7b6c09f 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -9,6 +9,7 @@ import ErrorBoundary from '../../ErrorBoundary'; import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentStateView from './subcomponents/MainContentStateView'; import TaskMasterPanel from './subcomponents/TaskMasterPanel'; +import PluginTabContent from '../../plugins/PluginTabContent'; import type { MainContentProps } from '../types/types'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; @@ -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 a9f095c..5b93fbb 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/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx new file mode 100644 index 0000000..c1d6578 --- /dev/null +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -0,0 +1,347 @@ +import { useState } from 'react'; +import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight } from 'lucide-react'; +import { usePlugins } from '../../contexts/PluginsContext'; +import PluginIcon from './PluginIcon'; +import type { Plugin } from '../../contexts/PluginsContext'; + +/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ +function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( +