feat: new plugin system

This commit is contained in:
Simos Mikelatos
2026-03-05 22:51:27 +00:00
parent f4615dfca3
commit b4169887ab
22 changed files with 2276 additions and 61 deletions

View File

@@ -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 = '<p>Hello!</p>';
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.

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello World Plugin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 24px;
transition: background-color 0.2s, color 0.2s;
}
body.dark { background: #1a1a2e; color: #e0e0e0; }
body.light { background: #ffffff; color: #1a1a2e; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
h2 { font-size: 1.1rem; margin-top: 24px; margin-bottom: 8px; opacity: 0.8; }
.context { font-size: 0.85rem; opacity: 0.7; margin-top: 16px; }
.context dt { font-weight: 600; margin-top: 8px; }
.context dd { margin-left: 12px; }
button {
margin-top: 12px;
padding: 8px 16px;
border: 1px solid currentColor;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.85rem;
opacity: 0.8;
}
button:hover { opacity: 1; }
pre {
margin-top: 8px;
padding: 12px;
border-radius: 6px;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
}
body.dark pre { background: #2a2a3e; }
body.light pre { background: #f0f0f4; }
</style>
</head>
<body class="light">
<h1>Hello from a plugin!</h1>
<p>This tab is rendered inside a sandboxed iframe.</p>
<dl class="context" id="ctx">
<dt>Theme</dt><dd id="ctx-theme"></dd>
<dt>Project</dt><dd id="ctx-project"></dd>
<dt>Session</dt><dd id="ctx-session"></dd>
</dl>
<h2>Server RPC</h2>
<p style="font-size: 0.85rem; opacity: 0.7;">This calls the plugin's own Node.js server subprocess via the postMessage RPC bridge.</p>
<button id="btn-hello">Call GET /hello</button>
<button id="btn-echo">Call POST /echo</button>
<pre id="rpc-result">Click a button to make an RPC call...</pre>
<script>
// ── RPC helper ──────────────────────────────────────────────────
// Sends a request through the host's postMessage bridge, which
// proxies it to this plugin's server subprocess.
function callBackend(method, path, body) {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).slice(2);
function handler(event) {
if (event.data?.type === 'ccui:rpc-response' && event.data.requestId === requestId) {
window.removeEventListener('message', handler);
resolve(event.data);
}
}
window.addEventListener('message', handler);
window.parent.postMessage({
type: 'ccui:rpc',
requestId,
method,
path,
body: body || undefined,
}, '*');
});
}
// ── Context listener ────────────────────────────────────────────
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'ccui:context') return;
const { theme, project, session } = event.data;
document.body.className = theme || 'light';
document.getElementById('ctx-theme').textContent = theme || '—';
document.getElementById('ctx-project').textContent = project ? project.name : '(none)';
document.getElementById('ctx-session').textContent = session ? session.title || session.id : '(none)';
});
// Request context on load
window.parent.postMessage({ type: 'ccui:request-context' }, '*');
// ── RPC demo buttons ────────────────────────────────────────────
document.getElementById('btn-hello').addEventListener('click', async () => {
const result = await callBackend('GET', '/hello');
document.getElementById('rpc-result').textContent = JSON.stringify(result, null, 2);
});
document.getElementById('btn-echo').addEventListener('click', async () => {
const result = await callBackend('POST', '/echo', { greeting: 'Hello from the iframe!' });
document.getElementById('rpc-result').textContent = JSON.stringify(result, null, 2);
});
</script>
</body>
</html>

View File

@@ -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) => `
<div class="ps-skel" style="
height:10px;width:${w}%;background:${c.muted};border-radius:2px;
margin-bottom:8px;animation-delay:${i * 0.1}s
"></div>`).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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:50%;gap:14px">
<pre style="font-size:0.75rem;color:${c.muted};opacity:0.5;line-height:1.6;text-align:center">~/.projects/
└── (none selected)</pre>
<div style="font-size:0.72rem;color:${c.muted};letter-spacing:0.1em;text-transform:uppercase">select a project</div>
</div>`;
return;
}
if (!stats) {
root.innerHTML = `
<div style="margin-bottom:24px">
<div style="font-size:1.3rem;font-weight:700">${ctx.project.name}<span style="color:${c.accent}">▌</span></div>
<div style="font-size:0.7rem;color:${c.muted};margin-top:4px">${ctx.project.path}</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
${[0, 1, 2].map(i => `
<div style="background:${c.surface};border:1px solid ${c.border};border-radius:3px;padding:18px">
${skeletonRows(c, [65])}
${skeletonRows(c, [40])}
</div>`).join('')}
</div>
<div style="background:${c.surface};border:1px solid ${c.border};border-radius:3px;padding:18px;margin-bottom:12px">
${[75, 55, 38, 22, 14].map((w, i) => `
<div style="display:flex;gap:10px;align-items:center;margin-bottom:7px">
<div class="ps-skel" style="width:44px;height:8px;background:${c.muted};border-radius:2px;animation-delay:${i*0.08}s"></div>
<div class="ps-skel" style="width:${w}%;height:4px;background:${c.muted};border-radius:1px;animation-delay:${i*0.08}s"></div>
</div>`).join('')}
</div>`;
return;
}
const maxCount = stats.byExtension[0]?.[1] || 1;
root.innerHTML = `
<div class="ps-up" style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px">
<div style="min-width:0;flex:1">
<div style="font-size:1.3rem;font-weight:700;letter-spacing:-0.02em;word-break:break-all">
${ctx.project.name}<span style="color:${c.accent}">▌</span>
</div>
<div style="font-size:0.7rem;color:${c.muted};margin-top:4px;word-break:break-all">${ctx.project.path}</div>
</div>
<button id="ps-refresh" style="
flex-shrink:0;margin-left:16px;padding:5px 12px;
background:transparent;border:1px solid ${c.border};
color:${c.muted};font-family:${MONO};font-size:0.7rem;
border-radius:3px;cursor:pointer;letter-spacing:0.05em;
transition:all 0.15s;
" onmouseover="this.style.borderColor='${c.accent}';this.style.color='${c.accent}'"
onmouseout="this.style.borderColor='${c.border}';this.style.color='${c.muted}'">
↻ refresh
</button>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
${[
['files', stats.totalFiles, n => n.toLocaleString()],
['lines', stats.totalLines, n => n.toLocaleString()],
['total size', stats.totalSize, fmt],
].map(([label, val, fmt], i) => `
<div class="ps-up" style="
background:${c.surface};border:1px solid ${c.border};
border-radius:3px;padding:18px;animation-delay:${i*0.06}s
">
<div id="ps-m-${i}" style="
font-size:1.9rem;font-weight:700;letter-spacing:-0.04em;
line-height:1;color:${c.text};
">0</div>
<div style="font-size:0.65rem;color:${c.muted};margin-top:6px;letter-spacing:0.1em;text-transform:uppercase">${label}</div>
</div>`).join('')}
</div>
<div class="ps-up" style="
background:${c.surface};border:1px solid ${c.border};
border-radius:3px;padding:18px;margin-bottom:12px;animation-delay:0.12s
">
<div style="font-size:0.62rem;color:${c.muted};letter-spacing:0.12em;text-transform:uppercase;margin-bottom:14px">file types</div>
${stats.byExtension.map(([ext, count], i) => `
<div class="ps-up" style="display:flex;align-items:center;gap:10px;margin-bottom:7px;animation-delay:${0.15+i*0.035}s">
<div style="width:50px;font-size:0.68rem;text-align:right;color:${c.muted};flex-shrink:0">${ext}</div>
<div style="flex:1;height:4px;background:${c.border};border-radius:1px;overflow:hidden">
<div class="ps-bar" style="
height:100%;width:${Math.round((count/maxCount)*100)}%;
background:${PALETTE[i % PALETTE.length]};
animation-delay:${0.18+i*0.035}s;border-radius:1px;
"></div>
</div>
<div style="width:30px;font-size:0.68rem;color:${c.muted};text-align:right;flex-shrink:0">${count}</div>
</div>`).join('')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
${[
['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) => `
<div class="ps-up" style="
background:${c.surface};border:1px solid ${c.border};
border-radius:3px;padding:18px;animation-delay:${0.18+ci*0.05}s
">
<div style="font-size:0.62rem;color:${c.muted};letter-spacing:0.12em;text-transform:uppercase;margin-bottom:12px">${title}</div>
${rows.map(([name, val], ri) => `
<div class="ps-up" style="
display:flex;justify-content:space-between;align-items:baseline;
padding:4px 0;border-bottom:1px solid ${c.border};font-size:0.7rem;
animation-delay:${0.2+ci*0.05+ri*0.03}s;gap:8px;
">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;opacity:0.75" title="${name}">${name}</div>
<div style="color:${c.accent};flex-shrink:0;opacity:0.9">${val}</div>
</div>`).join('')}
</div>`).join('')}
</div>
`;
// 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 = `
<div style="padding:24px;font-size:0.78rem;color:${c.accent};opacity:0.8;font-family:${MONO}">
${err.message}
</div>`;
}
}
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 = '';
}

View File

@@ -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": []
}

View File

@@ -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 }));
});

View File

@@ -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);

273
server/routes/plugins.js Normal file
View File

@@ -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;

View File

@@ -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);
}

View File

@@ -0,0 +1,162 @@
import { spawn } from 'child_process';
import path from 'path';
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
// Map<pluginName, { process, port }>
const runningPlugins = new Map();
/**
* Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
* 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);
}
}
}

View File

@@ -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() {
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<PluginsProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
@@ -25,8 +27,9 @@ export default function App() {
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</TaskMasterProvider>
</TasksSettingsProvider>
</PluginsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -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 }) {
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
{navItems.map((item) => {
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
setActiveTab(item.id);
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
isActive
@@ -81,6 +86,62 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl w-full relative touch-manipulation transition-all duration-200 active:scale-95 ${
isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
More
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="absolute bottom-full mb-2 right-0 min-w-[180px] py-1.5 rounded-xl border border-border/40 bg-popover shadow-lg z-[60] animate-in fade-in slide-in-from-bottom-2 duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex items-center gap-2.5 w-full px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'text-primary bg-primary/8'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -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 && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
{activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden">
<PluginTabContent
pluginName={activeTab.replace('plugin:', '')}
selectedProject={selectedProject}
selectedSession={selectedSession}
/>
</div>
)}
</div>
<EditorSidebar

View File

@@ -1,8 +1,17 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import {
MessageSquare,
Terminal,
Folder,
GitBranch,
ClipboardCheck,
type LucideIcon,
} from 'lucide-react';
import Tooltip from '../../../Tooltip';
import type { AppTab } from '../../../../types/app';
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/PluginIcon';
type MainContentTabSwitcherProps = {
activeTab: AppTab;
@@ -10,20 +19,32 @@ type MainContentTabSwitcherProps = {
shouldShowTasksTab: boolean;
};
type TabDefinition = {
type BuiltInTab = {
kind: 'builtin';
id: AppTab;
labelKey: string;
icon: LucideIcon;
};
const BASE_TABS: TabDefinition[] = [
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
type PluginTab = {
kind: 'plugin';
id: AppTab;
label: string;
pluginName: string;
iconFile: string;
};
type TabDefinition = BuiltInTab | PluginTab;
const BASE_TABS: BuiltInTab[] = [
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
];
const TASKS_TAB: TabDefinition = {
const TASKS_TAB: BuiltInTab = {
kind: 'builtin',
id: 'tasks',
labelKey: 'tabs.tasks',
icon: ClipboardCheck,
@@ -35,17 +56,30 @@ export default function MainContentTabSwitcher({
shouldShowTasksTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const pluginTabs: PluginTab[] = plugins
.filter((p) => 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 (
<div className="inline-flex items-center bg-muted/60 rounded-lg p-[3px] gap-[2px]">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.id === activeTab;
const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
return (
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
<Tooltip key={tab.id} content={displayLabel} position="bottom">
<button
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 px-2.5 py-[5px] text-sm font-medium rounded-md transition-all duration-150 ${
@@ -54,8 +88,16 @@ export default function MainContentTabSwitcher({
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
{tab.kind === 'builtin' ? (
<tab.icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
) : (
<PluginIcon
pluginName={tab.pluginName}
iconFile={tab.iconFile}
className="w-3.5 h-3.5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full"
/>
)}
<span className="hidden lg:inline">{displayLabel}</span>
</button>
</Tooltip>
);

View File

@@ -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({
) : (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">
{getTabTitle(activeTab, shouldShowTasksTab, t)}
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>

View File

@@ -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 (
<label className="relative inline-flex items-center cursor-pointer select-none">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<div
className={`
relative w-9 h-5 rounded-full transition-colors duration-200
bg-muted peer-checked:bg-emerald-500
after:absolute after:content-[''] after:top-[2px] after:left-[2px]
after:w-4 after:h-4 after:rounded-full after:bg-white after:shadow-sm
after:transition-transform after:duration-200
peer-checked:after:translate-x-4
`}
/>
</label>
);
}
/* ─── Server Dot ────────────────────────────────────────────────────────── */
function ServerDot({ running }: { running: boolean }) {
if (!running) return null;
return (
<span className="relative flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
</span>
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400 tracking-wide uppercase">
running
</span>
</span>
);
}
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
type PluginCardProps = {
plugin: Plugin;
index: number;
onToggle: (enabled: boolean) => void;
onUpdate: () => void;
onUninstall: () => void;
updating: boolean;
confirmingUninstall: boolean;
onCancelUninstall: () => void;
updateError: string | null;
};
function PluginCard({
plugin,
index,
onToggle,
onUpdate,
onUninstall,
updating,
confirmingUninstall,
onCancelUninstall,
updateError,
}: PluginCardProps) {
const accentColor = plugin.enabled
? 'bg-emerald-500'
: 'bg-muted-foreground/20';
return (
<div
className="relative flex rounded-md border border-border bg-card overflow-hidden transition-opacity duration-200"
style={{
opacity: plugin.enabled ? 1 : 0.65,
animationDelay: `${index * 40}ms`,
}}
>
{/* Left accent bar */}
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
<div className="flex-1 p-3.5 min-w-0">
{/* Header row */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex-shrink-0 w-4 h-4 text-foreground/80">
<PluginIcon
pluginName={plugin.name}
iconFile={plugin.icon}
className="w-4 h-4 [&>svg]:w-full [&>svg]:h-full"
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-foreground leading-none">
{plugin.displayName}
</span>
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
v{plugin.version}
</span>
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{plugin.type}
</span>
<ServerDot running={!!plugin.serverRunning} />
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-1 leading-snug">
{plugin.description}
</p>
)}
{plugin.author && (
<p className="text-[10px] font-mono text-muted-foreground/60 mt-0.5">
{plugin.author}
</p>
)}
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={onUpdate}
disabled={updating}
title="Pull latest from git"
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
{updating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={onUninstall}
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
className={`p-1.5 rounded transition-colors ${
confirmingUninstall
? 'text-red-500 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30'
: 'text-muted-foreground hover:text-red-500 hover:bg-muted'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
</div>
</div>
{/* Confirm uninstall banner */}
{confirmingUninstall && (
<div className="mt-3 flex items-center justify-between gap-3 px-3 py-2 rounded bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800/50">
<span className="text-xs text-red-600 dark:text-red-400">
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone.
</span>
<div className="flex gap-1.5">
<button
onClick={onCancelUninstall}
className="text-xs px-2.5 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={onUninstall}
className="text-xs px-2.5 py-1 rounded border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 font-medium transition-colors"
>
Remove
</button>
</div>
</div>
)}
{/* Update error */}
{updateError && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-red-500">
<ServerCrash className="w-3 h-3 flex-shrink-0" />
<span>{updateError}</span>
</div>
)}
</div>
</div>
);
}
/* ─── Empty State ───────────────────────────────────────────────────────── */
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="font-mono text-muted-foreground/30 text-xs leading-relaxed mb-4 select-none">
<div>~/.claude-code-ui/plugins/</div>
<div className="flex items-center justify-center gap-1 mt-1">
<ChevronRight className="w-3 h-3" />
<span>(empty)</span>
</div>
</div>
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Install from git or drop a folder in the plugins directory
</p>
</div>
);
}
/* ─── Main Component ────────────────────────────────────────────────────── */
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<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
const handleUpdate = async (name: string) => {
setUpdatingPlugin(name);
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name);
if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: 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 (
<div className="space-y-5">
{/* Header */}
<div className="flex items-baseline justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground/60 mb-1">
Plugins
</h3>
<p className="text-xs text-muted-foreground">
Extend the interface with custom tabs. Drop a folder in{' '}
<code className="font-mono text-[10px] bg-muted px-1.5 py-0.5 rounded">
~/.claude-code-ui/plugins/
</code>{' '}
or install from git.
</p>
</div>
{!loading && plugins.length > 0 && (
<span className="font-mono text-xs text-muted-foreground/50 tabular-nums">
{plugins.filter((p) => p.enabled).length}/{plugins.length}
</span>
)}
</div>
{/* Install from Git */}
<div className="rounded-md border border-border bg-card p-3.5">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-3.5 h-3.5 text-muted-foreground/60" />
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Install from git
</span>
</div>
<div className="flex items-center gap-0 rounded-md border border-border bg-background focus-within:ring-1 focus-within:ring-ring overflow-hidden">
<span className="flex-shrink-0 pl-3 pr-1.5 font-mono text-xs text-muted-foreground/50 select-none">
$
</span>
<input
type="text"
value={gitUrl}
onChange={(e) => {
setGitUrl(e.target.value);
setInstallError(null);
}}
placeholder="git clone https://github.com/user/my-plugin"
className="flex-1 px-1.5 py-2.5 text-xs font-mono bg-transparent text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
}}
/>
<button
onClick={handleInstall}
disabled={installing || !gitUrl.trim()}
className="flex-shrink-0 px-4 py-2.5 text-xs font-medium bg-foreground text-background hover:opacity-90 disabled:opacity-30 transition-opacity border-l border-border"
>
{installing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
'Install'
)}
</button>
</div>
{installError && (
<p className="mt-2 text-xs font-mono text-red-500">{installError}</p>
)}
</div>
{/* Plugin List */}
<div className="space-y-2">
{loading ? (
<div className="flex items-center gap-2 justify-center py-10 text-xs text-muted-foreground font-mono">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
scanning plugins
</div>
) : plugins.length === 0 ? (
<EmptyState />
) : (
plugins.map((plugin, index) => (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugin === plugin.name}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app';
type PluginTabContentProps = {
pluginName: string;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
};
type PluginContext = {
theme: 'dark' | 'light';
project: { name: string; path: string } | null;
session: { id: string; title: string } | null;
};
function buildContext(
isDarkMode: boolean,
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): PluginContext {
return {
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
: null,
session: selectedSession
? { id: selectedSession.id, title: selectedSession.title }
: null,
};
}
export default function PluginTabContent({
pluginName,
selectedProject,
selectedSession,
}: PluginTabContentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useTheme();
const { plugins } = usePlugins();
// Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName);
// Keep contextRef current and notify the mounted plugin on every context change
useEffect(() => {
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
contextRef.current = ctx;
for (const cb of contextCallbacksRef.current) {
try { cb(ctx); } catch { /* plugin error — ignore */ }
}
}, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => {
if (!containerRef.current) return;
let active = true;
const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js';
(async () => {
try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text();
const blob = new Blob([jsText], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// @vite-ignore
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
if (!active || !containerRef.current) return;
moduleRef.current = mod;
const api = {
get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacksRef.current.add(cb);
return () => contextCallbacksRef.current.delete(cb);
},
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
const cleanPath = String(path).replace(/^\//, '');
const res = await authenticatedFetch(
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
{
method: method || 'GET',
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
},
);
if (!res.ok) throw new Error(`RPC error ${res.status}`);
return res.json();
},
};
await mod.mount?.(container, api);
} catch (err) {
if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
}
}
})();
return () => {
active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacksRef.current.clear();
moduleRef.current = null;
};
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -10,6 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
@@ -176,6 +177,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
<CredentialsSettingsTab />
</div>
)}
{activeTab === 'plugins' && (
<div className="space-y-6 md:space-y-8">
<PluginSettingsTab />
</div>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { GitBranch, Key } from 'lucide-react';
import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
type MainTabConfig = {
id: SettingsMainTab;
labelKey: string;
labelKey?: string;
label?: string;
icon?: typeof GitBranch;
};
@@ -19,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -44,7 +46,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
}`}
>
{Icon && <Icon className="w-4 h-4 inline mr-2" />}
{t(tab.labelKey)}
{tab.labelKey ? t(tab.labelKey) : tab.label}
</button>
);
})}

View File

@@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { authenticatedFetch } from '../utils/api';
export type Plugin = {
name: string;
displayName: string;
version: string;
description: string;
author: string;
icon: string;
type: 'iframe' | 'react' | 'module';
slot: 'tab';
entry: string;
server: string | null;
permissions: string[];
enabled: boolean;
serverRunning: boolean;
dirName: string;
};
type PluginsContextValue = {
plugins: Plugin[];
loading: boolean;
refreshPlugins: () => Promise<void>;
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
togglePlugin: (name: string, enabled: boolean) => Promise<void>;
};
const PluginsContext = createContext<PluginsContextValue | null>(null);
export function usePlugins() {
const context = useContext(PluginsContext);
if (!context) {
throw new Error('usePlugins must be used within a PluginsProvider');
}
return context;
}
export function PluginsProvider({ children }: { children: ReactNode }) {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [loading, setLoading] = useState(true);
const refreshPlugins = useCallback(async () => {
try {
const res = await authenticatedFetch('/api/plugins');
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
}
} catch (err) {
console.error('[Plugins] Failed to fetch plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshPlugins();
}, [refreshPlugins]);
const installPlugin = useCallback(async (url: string) => {
try {
const res = await authenticatedFetch('/api/plugins/install', {
method: 'POST',
body: JSON.stringify({ url }),
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Install failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
}
}, [refreshPlugins]);
const uninstallPlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Uninstall failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
}
}, [refreshPlugins]);
const updatePlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Update failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
}
}, [refreshPlugins]);
const togglePlugin = useCallback(async (name: string, enabled: boolean) => {
try {
await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
await refreshPlugins();
} catch (err) {
console.error('[Plugins] Failed to toggle plugin:', err);
}
}, [refreshPlugins]);
return (
<PluginsContext.Provider value={{ plugins, loading, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
{children}
</PluginsContext.Provider>
);
}

View File

@@ -106,10 +106,14 @@ const isUpdateAdditive = (
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
};
const readPersistedTab = (): AppTab => {
try {
const stored = localStorage.getItem('activeTab');
if (stored && VALID_TABS.has(stored)) {
if (stored && isValidTab(stored)) {
return stored as AppTab;
}
} catch {

View File

@@ -1,6 +1,6 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession {
id: string;