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