fix: coderabbit changes and new plugin name & repo

This commit is contained in:
simosmik
2026-03-06 11:50:01 +00:00
parent a09aa5f68e
commit 0a3e22905f
11 changed files with 122 additions and 768 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "plugins/starter"]
path = plugins/starter
url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git

View File

@@ -1,22 +1,24 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---
## Screenshots
<div align="center">

View File

@@ -1,227 +0,0 @@
# 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

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
<line x1="2" y1="20" x2="22" y2="20"/>
</svg>

Before

Width:  |  Height:  |  Size: 353 B

View File

@@ -1,114 +0,0 @@
<!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

@@ -1,272 +0,0 @@
/**
* 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

@@ -1,13 +0,0 @@
{
"name": "project-stats",
"displayName": "Project Stats",
"version": "1.0.0",
"description": "Scans the current project and shows file counts, lines of code, file-type breakdown, largest files, and recently modified files.",
"author": "CloudCLI UI",
"icon": "icon.svg",
"type": "module",
"slot": "tab",
"entry": "index.js",
"server": "server.js",
"permissions": []
}

View File

@@ -1,105 +0,0 @@
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

@@ -7,7 +7,7 @@ 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_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab'];
export function getPluginsDir() {
@@ -96,7 +96,7 @@ export function scanPlugins() {
description: manifest.description || '',
author: manifest.author || '',
icon: manifest.icon || 'Puzzle',
type: manifest.type || 'iframe',
type: manifest.type || 'module',
slot: manifest.slot || 'tab',
entry: manifest.entry,
server: manifest.server || null,
@@ -157,7 +157,24 @@ export function installPluginFromGit(url) {
return reject(new Error(`Plugin directory "${repoName}" already exists`));
}
const gitProcess = spawn('git', ['clone', '--depth', '1', url, targetDir], {
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
const cleanupTemp = () => {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
};
const finalize = (manifest) => {
try {
fs.renameSync(tempDir, targetDir);
} catch (err) {
cleanupTemp();
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
}
resolve(manifest);
};
const gitProcess = spawn('git', ['clone', '--depth', '1', url, tempDir], {
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -166,15 +183,14 @@ export function installPluginFromGit(url) {
gitProcess.on('close', (code) => {
if (code !== 0) {
// Clean up failed clone
try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch {}
cleanupTemp();
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
}
// Validate manifest exists
const manifestPath = path.join(targetDir, 'manifest.json');
const manifestPath = path.join(tempDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
fs.rmSync(targetDir, { recursive: true, force: true });
cleanupTemp();
return reject(new Error('Cloned repository does not contain a manifest.json'));
}
@@ -182,42 +198,44 @@ export function installPluginFromGit(url) {
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
fs.rmSync(targetDir, { recursive: true, force: true });
cleanupTemp();
return reject(new Error('manifest.json is not valid JSON'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
fs.rmSync(targetDir, { recursive: true, force: true });
cleanupTemp();
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');
const packageJsonPath = path.join(tempDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: targetDir,
cwd: tempDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
console.warn(`[Plugins] npm install for ${repoName} exited with code ${npmCode}`);
cleanupTemp();
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
}
resolve(manifest);
finalize(manifest);
});
npmProcess.on('error', () => {
// npm not available, continue anyway
resolve(manifest);
npmProcess.on('error', (err) => {
cleanupTemp();
reject(err);
});
} else {
resolve(manifest);
finalize(manifest);
}
});
gitProcess.on('error', (err) => {
cleanupTemp();
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
@@ -265,8 +283,13 @@ export function updatePluginFromGit(name) {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', () => resolve(manifest));
npmProcess.on('error', () => resolve(manifest));
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
}
resolve(manifest);
});
npmProcess.on('error', (err) => reject(err));
} else {
resolve(manifest);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight } from 'lucide-react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight, ShieldAlert, ExternalLink, BookOpen } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
import type { Plugin } from '../../contexts/PluginsContext';
@@ -314,6 +314,43 @@ export default function PluginSettingsTab() {
{installError && (
<p className="mt-2 text-xs font-mono text-red-500">{installError}</p>
)}
<p className="mt-2.5 flex items-start gap-1.5 text-[11px] text-muted-foreground/60 leading-snug">
<ShieldAlert className="w-3 h-3 mt-px flex-shrink-0" />
<span>
Plugins run with full access to the host page. Only install plugins
whose source code you have reviewed or from authors you trust.
</span>
</p>
</div>
{/* Build your own */}
<div className="flex items-center gap-4 rounded-md border border-dashed border-border/60 px-4 py-3">
<BookOpen className="w-4 h-4 text-muted-foreground/50 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
Build your own plugin or start from an example.
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<a
href="https://github.com/cloudcli-ai/cloudcli-plugin-starter"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Starter plugin <ExternalLink className="w-3 h-3" />
</a>
<span className="text-border">|</span>
<a
href="https://cloudcli.ai/docs/plugin-overview"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Docs <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
{/* Plugin List */}

View File

@@ -9,7 +9,7 @@ export type Plugin = {
description: string;
author: string;
icon: string;
type: 'iframe' | 'react' | 'module';
type: 'react' | 'module';
slot: 'tab';
entry: string;
server: string | null;
@@ -22,11 +22,12 @@ export type Plugin = {
type PluginsContextValue = {
plugins: Plugin[];
loading: boolean;
pluginsError: string | null;
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>;
togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;
};
const PluginsContext = createContext<PluginsContextValue | null>(null);
@@ -42,6 +43,7 @@ export function usePlugins() {
export function PluginsProvider({ children }: { children: ReactNode }) {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [loading, setLoading] = useState(true);
const [pluginsError, setPluginsError] = useState<string | null>(null);
const refreshPlugins = useCallback(async () => {
try {
@@ -49,8 +51,20 @@ export function PluginsProvider({ children }: { children: ReactNode }) {
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
setPluginsError(null);
} else {
let errorMessage = `Failed to fetch plugins (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
errorMessage = res.statusText || errorMessage;
}
setPluginsError(errorMessage);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch plugins';
setPluginsError(message);
console.error('[Plugins] Failed to fetch plugins:', err);
} finally {
setLoading(false);
@@ -110,20 +124,32 @@ export function PluginsProvider({ children }: { children: ReactNode }) {
}
}, [refreshPlugins]);
const togglePlugin = useCallback(async (name: string, enabled: boolean) => {
const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {
try {
await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
let errorMessage = `Toggle failed (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
// response body wasn't JSON, use status text
errorMessage = res.statusText || errorMessage;
}
return { success: false, error: errorMessage };
}
await refreshPlugins();
return { success: true, error: null };
} catch (err) {
console.error('[Plugins] Failed to toggle plugin:', err);
return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };
}
}, [refreshPlugins]);
return (
<PluginsContext.Provider value={{ plugins, loading, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
<PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
{children}
</PluginsContext.Provider>
);