diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md new file mode 100644 index 0000000..6d0e527 --- /dev/null +++ b/examples/plugins/hello-world/README.md @@ -0,0 +1,127 @@ +# Hello World Plugin + +A minimal example showing how to build a plugin for Claude Code UI. + +## How plugins work + +A plugin's UI runs client-side inside a sandboxed iframe. The backend handles plugin lifecycle (install, update, uninstall) and serves the plugin's files as static assets. + +``` +┌─────────────────────────────────────────────────┐ +│ Backend (server) │ +│ │ +│ Lifecycle (spawns child processes): │ +│ git clone / git pull Install & update │ +│ npm install Dependency setup │ +│ │ +│ Runtime: │ +│ GET /api/plugins List plugins │ +│ GET /api/plugins/:name/assets/* Serve files │ +│ PUT /api/plugins/:name/enable Toggle on/off │ +│ DELETE /api/plugins/:name Uninstall │ +└──────────────────────┬──────────────────────────┘ + │ serves static files +┌──────────────────────▼──────────────────────────┐ +│ Frontend (browser) │ +│ │ +│ Plugin iframe ◄──postMessage──► Host app │ +│ (sandboxed) ccui:context │ +│ ccui:request-context │ +└─────────────────────────────────────────────────┘ +``` + +## Plugin structure + +A plugin is a directory with at minimum two files: + +``` +my-plugin/ + manifest.json # Required — plugin metadata + index.html # Entry point (referenced by manifest.entry) + styles.css # Optional — any static assets alongside entry + app.js # Optional — JS loaded by your HTML +``` + +All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. Use relative paths in your HTML to reference them (e.g., ``, ` + + diff --git a/examples/plugins/hello-world/manifest.json b/examples/plugins/hello-world/manifest.json new file mode 100644 index 0000000..ca8e0c8 --- /dev/null +++ b/examples/plugins/hello-world/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "hello-world", + "displayName": "Hello World", + "version": "1.0.0", + "description": "A minimal example plugin that demonstrates the plugin API.", + "author": "Claude Code UI", + "icon": "Puzzle", + "type": "iframe", + "slot": "tab", + "entry": "index.html", + "permissions": [] +} diff --git a/server/index.js b/server/index.js index 8f25fc2..76c2cad 100755 --- a/server/index.js +++ b/server/index.js @@ -64,6 +64,7 @@ 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 { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -389,6 +390,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); diff --git a/server/routes/plugins.js b/server/routes/plugins.js new file mode 100644 index 0000000..1aa4d54 --- /dev/null +++ b/server/routes/plugins.js @@ -0,0 +1,137 @@ +import express from 'express'; +import path from 'path'; +import mime from 'mime-types'; +import fs from 'fs'; +import { + scanPlugins, + getPluginsConfig, + savePluginsConfig, + resolvePluginAssetPath, + installPluginFromGit, + updatePluginFromGit, + uninstallPlugin, +} from '../utils/plugin-loader.js'; + +const router = express.Router(); + +// GET / — List all installed plugins +router.get('/', (req, res) => { + try { + const plugins = scanPlugins(); + 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 +router.put('/:name/enable', (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); + + 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); + 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 +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 manifest = await updatePluginFromGit(pluginName); + res.json({ success: true, plugin: manifest }); + } catch (err) { + res.status(400).json({ error: 'Failed to update plugin', details: err.message }); + } +}); + +// DELETE /:name — Uninstall plugin +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' }); + } + + 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..4339c96 --- /dev/null +++ b/server/utils/plugin-loader.js @@ -0,0 +1,287 @@ +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']; +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, + 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/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:') && ( +
+ +
+ )}
= { + Puzzle, + Box, + Database, + Globe, + Terminal, + Wrench, + Zap, + BarChart3, + Folder, + MessageSquare, + GitBranch, +}; type MainContentTabSwitcherProps = { activeTab: AppTab; @@ -12,7 +42,8 @@ type MainContentTabSwitcherProps = { type TabDefinition = { id: AppTab; - labelKey: string; + labelKey?: string; + label?: string; icon: LucideIcon; }; @@ -35,17 +66,29 @@ export default function MainContentTabSwitcher({ shouldShowTasksTab, }: MainContentTabSwitcherProps) { const { t } = useTranslation(); + const { plugins } = usePlugins(); - const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS; + const builtInTabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS; + + const pluginTabs: TabDefinition[] = plugins + .filter((p) => p.enabled) + .map((p) => ({ + id: `plugin:${p.name}` as AppTab, + label: p.displayName, + icon: PLUGIN_ICON_MAP[p.icon] || Puzzle, + })); + + const tabs = [...builtInTabs, ...pluginTabs]; return (
{tabs.map((tab) => { const Icon = tab.icon; const isActive = tab.id === activeTab; + const displayLabel = tab.labelKey ? 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..d6aeac5 --- /dev/null +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react'; +import { Puzzle, Trash2, RefreshCw, Power, PowerOff, GitBranch, Loader2 } from 'lucide-react'; +import { usePlugins } from '../../contexts/PluginsContext'; +import { Button } from '../ui/button'; + +export default function PluginSettingsTab() { + const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } = usePlugins(); + const [gitUrl, setGitUrl] = useState(''); + const [installing, setInstalling] = useState(false); + const [installError, setInstallError] = useState(null); + const [confirmUninstall, setConfirmUninstall] = useState(null); + const [updatingPlugin, setUpdatingPlugin] = useState(null); + const [updateError, setUpdateError] = useState<{ name: string; message: string } | null>(null); + + const handleUpdate = async (name: string) => { + setUpdatingPlugin(name); + setUpdateError(null); + const result = await updatePlugin(name); + if (!result.success) { + setUpdateError({ name, message: result.error || 'Update failed' }); + } + setUpdatingPlugin(null); + }; + + const handleInstall = async () => { + if (!gitUrl.trim()) return; + setInstalling(true); + setInstallError(null); + + const result = await installPlugin(gitUrl.trim()); + if (result.success) { + setGitUrl(''); + } else { + setInstallError(result.error || 'Installation failed'); + } + setInstalling(false); + }; + + const handleUninstall = async (name: string) => { + if (confirmUninstall !== name) { + setConfirmUninstall(name); + return; + } + await uninstallPlugin(name); + setConfirmUninstall(null); + }; + + return ( +
+ {/* Header */} +
+

+ + Plugins +

+

+ Extend the app with custom tab plugins. Place plugins in{' '} + ~/.claude-code-ui/plugins/{' '} + or install from a git repository. +

+
+ + {/* Install from Git */} +
+
+ + Install from Git +
+
+ { + setGitUrl(e.target.value); + setInstallError(null); + }} + placeholder="https://github.com/user/my-plugin.git" + className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === 'Enter') void handleInstall(); + }} + /> + +
+ {installError && ( +

{installError}

+ )} +
+ + {/* Plugin List */} +
+ {loading ? ( +
+ + Loading plugins... +
+ ) : plugins.length === 0 ? ( +
+ +

No plugins installed

+

+ Install a plugin from git or place one in the plugins directory. +

+
+ ) : ( + plugins.map((plugin) => ( +
+
+
+
+ {plugin.displayName} + + v{plugin.version} + + + {plugin.type} + +
+ {plugin.description && ( +

{plugin.description}

+ )} + {plugin.author && ( +

by {plugin.author}

+ )} +
+ +
+ + + + + +
+
+ + {updateError?.name === plugin.name && ( +

{updateError.message}

+ )} + + {confirmUninstall === plugin.name && ( +
+ Uninstall {plugin.displayName}? This cannot be undone. +
+ + +
+
+ )} +
+ )) + )} +
+
+ ); +} diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx new file mode 100644 index 0000000..b38a453 --- /dev/null +++ b/src/components/plugins/PluginTabContent.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { Project, ProjectSession } from '../../types/app'; + +type PluginTabContentProps = { + pluginName: string; + selectedProject: Project | null; + selectedSession: ProjectSession | null; +}; + +export default function PluginTabContent({ + pluginName, + selectedProject, + selectedSession, +}: PluginTabContentProps) { + const iframeRef = useRef(null); + const { isDarkMode } = useTheme(); + + const iframeSrc = `/api/plugins/${encodeURIComponent(pluginName)}/assets/index.html`; + + // Send context to iframe when it loads or when context changes. + // Use '*' as targetOrigin because the sandbox (without allow-same-origin) gives the + // iframe an opaque origin that cannot be matched with a specific origin string. + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + const sendContext = () => { + iframe.contentWindow?.postMessage( + { + type: 'ccui:context', + theme: isDarkMode ? 'dark' : 'light', + project: selectedProject + ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } + : null, + session: selectedSession + ? { id: selectedSession.id, title: selectedSession.title } + : null, + }, + '*', + ); + }; + + iframe.addEventListener('load', sendContext); + + // Also send when context changes (iframe already loaded) + if (iframe.contentWindow) { + sendContext(); + } + + return () => { + iframe.removeEventListener('load', sendContext); + }; + }, [isDarkMode, selectedProject, selectedSession]); + + // Listen for messages from plugin iframe. + // We verify by event.source rather than event.origin because the sandboxed iframe + // (without allow-same-origin) has an opaque origin that shows up as "null". + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Only accept messages originating from our plugin iframe + if (event.source !== iframeRef.current?.contentWindow) return; + if (!event.data || typeof event.data !== 'object') return; + + const { type } = event.data; + + switch (type) { + case 'ccui:request-context': { + // Plugin is requesting current context + iframeRef.current?.contentWindow?.postMessage( + { + type: 'ccui:context', + theme: isDarkMode ? 'dark' : 'light', + project: selectedProject + ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } + : null, + session: selectedSession + ? { id: selectedSession.id, title: selectedSession.title } + : null, + }, + '*', + ); + break; + } + default: + break; + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [isDarkMode, selectedProject, selectedSession]); + + return ( +
+