diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md index 30377e01..bc31e85a 100644 --- a/examples/plugins/hello-world/README.md +++ b/examples/plugins/hello-world/README.md @@ -1,10 +1,10 @@ # Hello World Plugin -A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (iframe) and backend (server subprocess) capabilities. +A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (ES module) and backend (server subprocess) capabilities. ## How plugins work -A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The iframe communicates with its server through a postMessage RPC bridge (the host proxies the calls because the sandboxed iframe has no auth token). +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). ``` ┌─────────────────────────────────────────────────────────┐ @@ -30,10 +30,11 @@ A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally ┌───────────▼─────────────────────────▼───────────────────┐ │ Frontend (browser) │ │ │ -│ Plugin iframe ◄──postMessage──► Host app │ -│ (sandboxed) ccui:context │ -│ ccui:request-context │ -│ ccui:rpc / ccui:rpc-response │ +│ 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 │ └─────────────────────────────────────────────────────────┘ ``` @@ -42,12 +43,12 @@ A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally ``` my-plugin/ manifest.json # Required — plugin metadata - index.html # Frontend entry point (rendered in iframe) + 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/`. Use relative paths in your HTML (e.g., ``). +All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. ## manifest.json @@ -59,11 +60,11 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/ "description": "Short description shown in settings.", "author": "Your Name", "icon": "Puzzle", // Lucide icon name (see available icons below) - "type": "iframe", // Rendering method — only "iframe" is supported today + "type": "module", // "module" (default) or "iframe" (legacy) "slot": "tab", // Where the plugin appears — only "tab" is supported today - "entry": "index.html", // Frontend entry file, relative to plugin directory + "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 + "permissions": [] // Reserved for future use } ``` @@ -79,6 +80,56 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/ --- +## Frontend — Module API + +The host dynamically imports your entry file and calls `mount(container, api)`. When the plugin tab is closed or the plugin is disabled, `unmount(container)` is called. + +```js +// index.js + +export function mount(container, api) { + // api.context — current snapshot: { theme, project, session } + // api.onContextChange(cb) — subscribe, returns an unsubscribe function + // api.rpc(method, path, body?) — call the plugin's server subprocess + + container.innerHTML = '
Hello!
'; + + const unsub = api.onContextChange((ctx) => { + container.style.background = ctx.theme === 'dark' ? '#111' : '#fff'; + }); + + container._cleanup = unsub; +} + +export function unmount(container) { + if (typeof container._cleanup === 'function') container._cleanup(); + container.innerHTML = ''; +} +``` + +### Context object + +```js +api.context // always up to date +// { +// theme: "dark" | "light", +// project: { name: string, path: string } | null, +// session: { id: string, title: string } | null, +// } +``` + +### RPC helper + +```js +// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch +const data = await api.rpc('GET', '/hello'); + +// With a JSON body +const result = await api.rpc('POST', '/echo', { greeting: 'hi' }); +``` + +--- + ## Backend — Server subprocess Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle: @@ -140,70 +191,15 @@ server.listen(0, '127.0.0.1', () => { --- -## Frontend — Context API - -The host app sends a `ccui:context` message to the iframe whenever the theme, project, or session changes: - -```js -window.addEventListener('message', (event) => { - if (event.data?.type !== 'ccui:context') return; - - const { theme, project, session } = event.data; - // theme — "dark" | "light" - // project — { name, path } | null - // session — { id, title } | null -}); -``` - -To request the current context on load: - -```js -window.parent.postMessage({ type: 'ccui:request-context' }, '*'); -``` - -## Frontend — RPC bridge - -The sandboxed iframe cannot make authenticated API calls directly. Instead, it sends RPC requests via `postMessage`, and the host proxies them to the plugin's server subprocess: - -```js -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, - }, '*'); - }); -} - -// Usage -const result = await callBackend('GET', '/hello'); -// result = { type: 'ccui:rpc-response', requestId, status: 200, data: { message: 'Hello!' } } -``` - -The host receives `ccui:rpc`, makes an authenticated fetch to `/api/plugins/:name/rpc/*`, and returns the response as `ccui:rpc-response`. - ## 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 sandbox +### Frontend isolation -Plugins run in a sandboxed iframe with `allow-scripts allow-forms allow-popups`. They **cannot** access the host app's cookies, localStorage, or auth tokens (`allow-same-origin` is intentionally omitted). +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 diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx index aefab91e..37a9d268 100644 --- a/src/components/plugins/PluginTabContent.tsx +++ b/src/components/plugins/PluginTabContent.tsx @@ -38,7 +38,6 @@ export default function PluginTabContent({ selectedSession, }: PluginTabContentProps) { const containerRef = useRef