@clipbus/plugin-sdk
v0.8.7
Published
Typed SDK for authoring Clipbus plugins — runtime (Node.js) and UI (WebView) helpers generated from the Clipbus plugin wire contract.
Readme
@clipbus/plugin-sdk
Standalone, publishable SDK for Clipbus plugins. Provides typed helpers for the runtime (Node.js) and UI (WebView) sides of a plugin.
Installation
Install from npm:
npm install @clipbus/plugin-sdk{
"dependencies": {
"@clipbus/plugin-sdk": "^0.1.0"
}
}Then import the runtime, UI, or DOM entry point:
const { definePlugin } = require('@clipbus/plugin-sdk/runtime');
import { clipbus } from '@clipbus/plugin-sdk/ui';
import { autoFit } from '@clipbus/plugin-sdk/dom';Publishing prerequisite: the
clipbusnpm org/scope must exist before the firstnpm publish(a one-timenpmjs.comoperation under a logged-in account). Fallback unscoped name if@clipbusis unavailable:clipbus-plugin-sdk.
Runtime entry (@clipbus/plugin-sdk/runtime)
Used in Node.js plugin code to define capabilities and return results.
definePlugin(definition)
Validates and returns a plugin definition object. Throws if setup is not a function.
const { definePlugin } = require('@clipbus/plugin-sdk/runtime');
module.exports = definePlugin({
setup(init) {
return {
detectors: { 'my-detector': { detect(input, ctx) { /* ... */ } } },
attachmentRenderers: { 'my-renderer': { resolveAttachment(input, ctx) { /* ... */ } } },
actions: { 'my-action': { resolveSession(input, ctx) { /* ... */ } } }
};
}
});actionResult.text(value, options?)
Returns a locked { result: { resultKind: 'text', text }, userMessage } shape.
| Param | Type | Description |
|---|---|---|
| value | unknown | Coerced to string. null/undefined → "". |
| options.userMessage | string? | Optional message shown to the user. |
actionResult.none(options?)
Returns { result: { resultKind: 'none', text: null }, userMessage }.
actionResult.image(value, options?)
Returns a locked { result: { resultKind: 'image', imageTempPath, imageFormatHint }, userMessage } shape. Use this when an action produces an image result written to a temp path allocated via ctx.host.action.allocateImageTempPath().
| Param | Type | Description |
|---|---|---|
| value.imageTempPath | string | Path returned by allocateImageTempPath. |
| value.imageFormatHint | string? | Optional format hint (e.g. "png", "jpeg"). |
| options.userMessage | string? | Optional message shown to the user. |
messageHandlers — handle UI → Runtime RPC calls
Register handlers that respond to calls made from UI via clipbus.runtime.invoke. Each key is a string; the value is an async function receiving (request, ctx).
const { definePlugin } = require('@clipbus/plugin-sdk/runtime');
module.exports = definePlugin({
messageHandlers: {
'generate-image': async (req, ctx) => {
const { path } = await ctx.host.action.allocateImageTempPath({ formatHint: 'png' });
// ... write bytes to path ...
return { imageTempPath: path };
},
},
});defineMessage(key) — shared type contract for UI ↔ Runtime RPC
Creates a typed contract object. Import from @clipbus/plugin-sdk/runtime on the runtime side (gives .handle()) or from @clipbus/plugin-sdk/ui on the UI side (gives .invoke()).
// shared/contracts.ts (imported by both runtime and UI)
import { defineMessage } from '@clipbus/plugin-sdk/runtime';
export const GenerateImage = defineMessage<
{ prompt: string },
{ imageTempPath: string }
>('generate-image');
// runtime/index.ts
definePlugin({
messageHandlers: Object.fromEntries([
GenerateImage.handle(async (req, ctx) => {
const { path } = await ctx.host.action.allocateImageTempPath({ formatHint: 'png' });
return { imageTempPath: path };
}),
]),
});Host verbs (ctx.host.*) — runtime context only
These verbs are available on the ctx.host object passed to every runtime handler. All calls go through real Node IPC and return typed responses.
ctx.host.item
| Method | Returns | Description |
|---|---|---|
| setTags({tags}) | Promise<{tags: string[]}> | Replace item tags (requires setTags permission) |
| addTags({tags}) | Promise<{tags: string[]}> | Add tags to item |
| removeTags({tags}) | Promise<{tags: string[]}> | Remove tags from item |
| setPinned({pinned}) | Promise<{pinned: boolean}> | Pin or unpin the item |
| setAttachments(payload) | Promise<{}> | Replace attachments (requires setAttachment permission) |
| setSearchExtension(payload) | Promise<{}> | Replace search extension entries |
| materializeImagePath() | Promise<{path: string}> | Copies the item's image to a stable temp file. Idempotent across invocation. Only valid for image-type items. |
| readAttachment({attachmentType, attachmentKey}) | Promise<{payloadJson?: string}> | Returns the payloadJson string for the named attachment, or null if absent. |
ctx.host.action
| Method | Returns | Description |
|---|---|---|
| allocateImageTempPath({formatHint?}) | Promise<{path: string}> | Allocates a unique writable path for an image result. Consumed by actionResult.image({imageTempPath}). Cleaned up when invocation scope closes. |
ctx.host.asset
| Method | Returns | Description |
|---|---|---|
| registerImage({path}) | Promise<{url: string}> | Registers a Node-produced / local image file with the current session and returns an opaque clipbus-asset:// URL the WebView can render in <img>. The host validates the file, copies it into the session owner dir, then mints a session-scoped token. The real path never reaches JS. |
ctx.host.clipboard, ctx.host.navigation, ctx.host.settings
| Method | Returns | Description |
|---|---|---|
| clipboard.copyText({text}) | Promise<void> | Copy text to system clipboard |
| navigation.openUrl({url}) | Promise<void> | Open a URL |
| navigation.revealInFinder({path}) | Promise<void> | Show file path in Finder |
| navigation.openFilePath({path}) | Promise<void> | Open a file by path with default app |
| settings.get({key}) | Promise<{value: string \| null}> | Read a single plugin setting |
| settings.getAll() | Promise<{settings: Record<string, string>}> | Read all plugin settings |
ctx.host.locale
| Method | Returns | Description |
|---|---|---|
| locale.get() | Promise<{locale: string}> | Read the host's current interface locale. Values: "en", "ja", "zh-Hans". Wrap in try/catch when targeting older hosts that may not support this capability. |
DOM utilities (@clipbus/plugin-sdk/dom)
Optional sub-package for WebView plugins that need DOM manipulation utilities.
import { patchConsole, patchTextInputState, autoFit } from '@clipbus/plugin-sdk/dom';
// Patch console and text input state (call once at module load)
patchConsole();
patchTextInputState();
// Auto-fit WebView height with ResizeObserver
const dispose = await autoFit({ min: 120, max: 480, target: containerEl });
dispose(); // stop monitoringUI entry (@clipbus/plugin-sdk/ui)
Used in WebView plugin code (ES modules, Vite build). Exposes a single clipbus object.
import { clipbus } from '@clipbus/plugin-sdk/ui';
// No async ready() call needed — subscribers can be registered immediately.
const item = clipbus.item.current(); // may be undefined if bootstrap hasn't arrived yet
clipbus.item.on(next => { /* always fires once host pushes bootstrap */ });No clipbus.ready()
The SDK does not export clipbus.ready(). Observers (clipbus.<domain>.on(fn)) are context-neutral: register them at module load and the host's bootstrap push will trigger them. Use .current() for a synchronous snapshot; defend against undefined with ?. / ?? / early-return.
clipbus.item — Topic<PluginClipboardItem>
| Member | Shape | Description |
|---|---|---|
| clipbus.item.current() | () → PluginClipboardItem \| undefined | Read current item snapshot (may be undefined before bootstrap arrives) |
| clipbus.item.on(fn) | (fn) → Unsubscribe | Subscribe to item changes |
| clipbus.item.readAttachment({attachmentType, attachmentKey}) | Verb → {payloadJson?: string} | Read an attachment payload for the current item |
clipbus.item.attachment — OptionalTopic<PluginAttachmentPayload> (data layer; attachment context)
| Member | Shape | Description |
|---|---|---|
| clipbus.item.attachment.current() | () → PluginAttachmentPayload \| undefined | Current attachment payload |
| clipbus.item.attachment.on(fn) | (fn) → Unsubscribe | Subscribe to payload changes |
clipbus.attachmentRenderer — renderer operations (attachment context)
| Member | Shape | Description |
|---|---|---|
| clipbus.attachmentRenderer.setButtons({buttons}) | Verb | Replace native renderer button list (whole-list replace; first call overrides resolveAttachment seed). Strict wire shape — pass {buttons: [...]}. |
| clipbus.attachmentRenderer.onHostInvoke(fn) | Stream | Subscribe to host-dispatched button clicks; callback receives { buttonID } |
clipbus.theme — Topic<PluginThemeTokenSnapshot>
| Member | Shape | Description |
|---|---|---|
| clipbus.theme.current() | () → PluginThemeTokenSnapshot \| undefined | Read current theme tokens. The host injects __CLIPBUS_PLUGIN_THEME__ at WebView startup, so this is synchronously available in practice. |
| clipbus.theme.on(fn) | (fn) → Unsubscribe | Subscribe to theme updates via the clipbus-plugin-theme host event |
There is no clipbus.theme.refresh() or clipbus.theme.getThemeSnapshot() — the unified topic replaces both.
clipbus.locale — Topic<PluginLocalePayload>
| Member | Shape | Description |
|---|---|---|
| clipbus.locale.current() | () → PluginLocalePayload \| undefined | Read current locale. The host injects __CLIPBUS_PLUGIN_LOCALE__ at WebView startup, so this is synchronously available in practice. |
| clipbus.locale.on(fn) | (fn) → Unsubscribe | Subscribe to the clipbus-plugin-locale host event. The host locale is fixed per app session (changing it requires a restart), so this rarely fires after the initial value — it exists for parity with other topics. |
Locale values: "en", "ja", "zh-Hans" (the host's current interface language).
current() may return undefined when the host has not yet injected the value. Defend with a fallback at the call site: clipbus.locale.current()?.locale ?? 'en'.
clipbus.asset — local image URLs for <img> (any context)
Returns opaque clipbus-asset:// URLs so a WebView can render local images in <img> without ever seeing a real file path (the WebView sandbox blocks file://). The host resolves the token internally; tokens are session-scoped and invalidated when the owner is released.
| Member | Shape | Description |
|---|---|---|
| clipbus.asset.currentItemImageUrl() | Verb → {url?: string} | URL of the current item's own image. url is absent when the item is not an image. |
| clipbus.asset.pathReferenceImageUrl({index}) | Verb → {url?: string} | URL of the current item's path_reference entry at index. url is absent when out of range or the entry is not an image. |
Arbitrary / generated images are minted only on the runtime side via ctx.host.asset.registerImage({path}) (see above), so a WebView XSS cannot turn an arbitrary file into a renderable URL.
import { clipbus } from '@clipbus/plugin-sdk/ui';
const { url } = await clipbus.asset.currentItemImageUrl();
if (url) imgEl.src = url; // url is undefined for non-image itemsclipbus.runtime
| Member | Shape | Description |
|---|---|---|
| clipbus.runtime.invoke<TResp>({key, payload, timeoutMs?}) | Verb → TResp | Call a handler registered in the plugin's own Node runtime via messageHandlers. Strict wire shape — single object parameter. Default timeout: 30 s. Returns the handler's value directly (the SDK auto-unwraps the IPC envelope). Throws on handler error; err.name and err.data are preserved across the process boundary. |
import { clipbus, defineMessage } from '@clipbus/plugin-sdk/ui';
// Recommended: type-safe via defineMessage (shared with the runtime side)
const GenerateImage = defineMessage<{ prompt: string }, { imageTempPath: string }>('generate-image');
const { imageTempPath } = await GenerateImage.invoke({ prompt: 'a cat' }, { timeoutMs: 60_000 });
// Or bare interface — returns TResp directly (no { response } wrapper)
const { imageTempPath: path2 } = await clipbus.runtime.invoke<{ imageTempPath: string }>({
key: 'generate-image',
payload: { prompt: 'a cat' },
timeoutMs: 60_000,
});clipbus.action (action context only)
Namespace of verbs and sub-topics scoped to the action WebView. clipbus.action itself is not a Topic — it's a grouping. The mutation verbs below require the WebView to be in action context (clipbus.pluginContext.current()?.mode === 'action') and reject otherwise with PluginContextError.
| Member | Shape | Description |
|---|---|---|
| clipbus.action.setButtons({buttons}) | Verb | Replace native action button list (whole-list replace; first call overrides resolveSession seed). Strict wire shape — pass {buttons: [...]}. |
| clipbus.action.complete({result, userMessage?}) | Verb | Submit draft action result to host; triggers phase transition to .success |
| clipbus.action.onHostInvoke(fn) | Stream | Subscribe to host-dispatched button clicks; callback receives { buttonID } |
clipbus.action.draft — OptionalTopic<Record<string, unknown>>
Draft is read-only from the UI. The host pushes initialDraft (returned by resolveSession) as the topic value; the UI keeps form state locally and submits the final result via clipbus.action.complete(...).
| Member | Shape | Description |
|---|---|---|
| clipbus.action.draft.current() | () → Record<string, unknown> \| undefined | Current draft state pushed by host. Cast to your draft type at the call site. |
| clipbus.action.draft.on(fn) | (fn) → Unsubscribe | Subscribe to draft updates (rare — initialDraft is usually all the UI consumes). |
There is no clipbus.action.draft.update(...) or clipbus.action.draft.getDraftSnapshot(). To run runtime-side logic from the UI mid-edit (allocate image temp path, fetch external data), use clipbus.runtime.invoke(...) against your own messageHandlers.
PluginContextError
Context-bound verbs (e.g. clipbus.action.complete, clipbus.action.setButtons, clipbus.attachmentRenderer.setButtons) reject with PluginContextError when called from the wrong WebView pane:
import { clipbus, PluginContextError } from '@clipbus/plugin-sdk/ui';
try {
await clipbus.action.complete({ result: { resultKind: 'none' } });
} catch (e) {
if ((e as Error).name === 'PluginContextError') {
// current WebView is not an action context
}
}clipbus.window
| Member | Shape | Description |
|---|---|---|
| clipbus.window.setHeight({height}) | Verb | Report current WebView height (integer pixels). Strict wire shape — pass {height: number}, not a bare number. |
| clipbus.window.autoFit() | Verb | Tell the host to enter auto-fit mode for this WebView. Bounds come from manifest.attachmentRenderers[].height ("auto" or {min, max}). No options. |
For convenience, @clipbus/plugin-sdk/dom also exports an autoFit({min, max, target}) helper that wires up a ResizeObserver and calls clipbus.window.setHeight(...) on every layout change — see the DOM utilities section above.
clipbus.infoPanel — open the native info panel (any context)
Opens Clipbus's native detail panel from a plugin, rendered from a structured document (rich text / code / QR / key-value fields / tags / actions). Not permission-gated. Usable from both attachmentRenderer and action WebViews.
| Member | Shape | Description |
|---|---|---|
| clipbus.infoPanel.open({document}) | Verb → {panelID: string} | Open a panel; returns a panelID that keys the streams below. |
| clipbus.infoPanel.close({panelID}) | Verb | Close a panel this plugin opened. No-op for an unknown / not-owned panelID. |
| clipbus.infoPanel.onAction.on(fn) | Stream | Subscribe to button clicks; callback receives { panelID, buttonID }. |
| clipbus.infoPanel.onClose.on(fn) | Stream | Fires once when a panel closes (user-dismissed or close()); receives { panelID }. |
document shape — { icon?, title, subtitle?, badges?: string[], blocks: Block[], actions: Action[] }.
Block is a discriminated union on type:
| type | Fields |
|---|---|
| label | { text } |
| text | { text, style: 'body' \| 'mono' \| 'muted' } |
| code | { text, language: 'plain' \| 'json' } |
| qrCode | { value } |
| fields | { fields: { key, value, isMono? }[] } |
| tags | { tags: string[] } |
| divider | {} |
Action is { id, label, isPrimary? }.
Buttons are pure-notification. The host runs no built-in effect for a plugin button — every click is delivered to infoPanel.onAction, and the plugin performs its own side effect (e.g. call clipbus.clipboard.copyText(...) / clipbus.navigation.openUrl(...) in the handler). There is no host-side copy / openURL effect and no link text style.
const { panelID } = await clipbus.infoPanel.open({
document: {
title: "Details",
blocks: [
{ type: "text", text: "Tap a button below.", style: "body" },
{ type: "tags", tags: ["demo"] },
],
actions: [{ id: "copy", label: "Copy", isPrimary: true }],
},
});
clipbus.infoPanel.onAction.on(({ buttonID }) => {
if (buttonID === "copy") clipbus.clipboard.copyText({ text: "…" });
});
clipbus.infoPanel.onClose.on(({ panelID }) => { /* clean up */ });Shared-resource semantics. Info panels are a global resource — the host caps the total number of open panels (currently 12, shared across the app and all plugins) and reclaims the oldest non-focused window when the cap is exceeded; a reclaimed panel still fires onClose. Events and close() are scoped to the opening plugin session: onAction / onClose are delivered only to the plugin that opened the panel, and close() only affects that plugin's own panels. When a plugin's WebView is torn down its onAction / onClose subscriptions stop firing; any panel it left open stays on screen until the user dismisses it or the cap reclaims it.
clipbus.clipboard
| Member | Shape | Description |
|---|---|---|
| clipbus.clipboard.copyText({text}) | Verb | Copy text to system clipboard |
clipbus.navigation
| Member | Shape | Description |
|---|---|---|
| clipbus.navigation.openUrl({url}) | Verb | Open a URL |
| clipbus.navigation.revealInFinder({path}) | Verb | Reveal file path in Finder |
| clipbus.navigation.openFilePath({path}) | Verb | Open a file by path |
clipbus.settings
| Member | Shape | Description |
|---|---|---|
| clipbus.settings.get({key}) | Verb → {value: unknown \| null} | Read a plugin setting by key |
| clipbus.settings.getAll() | Verb → {settings: Record<string, unknown>} | Read all plugin settings |
clipbus.console / clipbus.textInput / clipbus.pluginContext
| Member | Shape | Description |
|---|---|---|
| clipbus.console.log({level, message}) | Verb | Write a log line to the host log (level: 'debug' \| 'info' \| 'warn' \| 'error') |
| clipbus.textInput.stateChanged({isFocused, isComposing}) | Verb | Notify host that a text input's focus / IME state changed |
| clipbus.pluginContext.current() | Topic | { mode: 'attachmentRenderer' \| 'action', pluginID } — read which kind of WebView this code is running in |
| clipbus.pluginContext.on(fn) | Topic | Subscribe (rare; context doesn't normally change after bootstrap) |
Strict wire shape: every verb takes a single object parameter (
{text},{url}, etc.), never a bare value. The single source of truth for every payload / response shape is API.md, generated fromprotocol/plugin/src/catalog.ts.
Types
All public types live in the generated data.generated.ts / capabilityClients.generated.ts and carry the Plugin prefix. The pre-plugin-api-shrink aliases without the prefix have been removed.
// Data types — re-exported from both /runtime and /ui
import type {
PluginClipboardItem,
PluginAttachmentPayload,
PluginAttachmentEntry,
PluginAttachmentRef,
PluginAttachmentMutationEntry,
PluginSearchExtensionEntry,
PluginContentEnvelope, // discriminated union: text | image | path_reference
PluginPathEntry, // { kind: 'file' | 'folder', path, displayName }
PluginDetectorArtifact,
PluginDetectorInput,
PluginResolveAttachmentInput,
PluginAttachmentResolveResult,
PluginResolveActionSessionInput,
PluginActionResolveResult,
PluginAutoRunActionInput,
PluginActionOperationResult,
PluginActionResult, // discriminated union: text | image | none
PluginActionButton,
PluginThemeTokens,
PluginThemeTokenSnapshot,
PluginConsoleLogLevel,
} from '@clipbus/plugin-sdk/runtime';
// Handler interfaces — runtime side only
import type {
PluginDetectorHandler,
PluginAttachmentRendererHandler,
PluginAutoRunActionHandler, // covers both auto-run and draft lifecycles
} from '@clipbus/plugin-sdk/runtime';
// definePlugin types — runtime side only
import type {
PluginRegistry,
MessageHandler,
MessageHandlerContext,
} from '@clipbus/plugin-sdk/runtime';For the exhaustive type list, run cd protocol/plugin && npm run codegen and read the synced data.generated.ts next to this file.
Manifest validation (@clipbus/plugin-sdk/validate)
The ./validate entry point provides a manifest validator that stays aligned with the
host's manifest validation rules. Use it in CI or npm run verify to catch errors at
development time — the same errors the host reports at install time.
const { validateManifest, MANIFEST_VALIDATION_CODES } = require('@clipbus/plugin-sdk/validate');
const manifest = JSON.parse(require('fs').readFileSync('manifest.json', 'utf8'));
const issues = validateManifest(manifest, { phase: 'manifestOnly' });
// issues: Array<{ code: string; message: string }>validateManifest(manifest, options?)
| Parameter | Type | Description |
|---|---|---|
| manifest | unknown | Parsed manifest.json content (JSON.parse output). |
| options.phase | 'manifestOnly' \| 'runtimeLoadable' | 'manifestOnly' (default): pure JSON rules only. 'runtimeLoadable': also checks file existence and path containment; requires rootDir. |
| options.rootDir | string | Absolute path to the plugin root. Required for 'runtimeLoadable'. |
Returns ManifestValidationIssue[]. An empty array means the manifest is valid.
Unlike the host validator (which stops at the first error), this function collects all issues in a single pass — better DX for development.
MANIFEST_VALIDATION_CODES
readonly string[] — all 26 error code strings, each a verbatim camelCase name that
matches the corresponding host error. Use for exhaustive handling or parity checks.
CLI: clipbus-validate-manifest
Installed automatically when @clipbus/plugin-sdk is a dependency:
npx clipbus-validate-manifest [pluginDir] [--runtime]pluginDir— path to the plugin directory containingmanifest.json(default: cwd).--runtime— useruntimeLoadablephase (checks file existence in addition to JSON rules).
Exits 0 on success, 1 if any issues are found.
Preview harness (@clipbus/plugin-sdk/preview)
Framework-neutral development-time workbench for iterating on plugin UI without a real macOS host process. Provides wire injection (host → plugin bootstrap), a bidirectional fake host (plugin → host calls go to a call log panel), and viewport height tracking via clipbus.window.setHeight.
import { createPreviewWorkbench } from '@clipbus/plugin-sdk/preview';
createPreviewWorkbench(document.getElementById('app')!, {
scenarios,
mount(slotEl, { scenario }) {
const app = createApp(MyPlugin);
app.mount(slotEl);
return () => app.unmount();
},
});See docs/preview.md for PreviewScenario / PreviewViewport field reference and full harness API.
See also
- API.md — autoritative API reference, regenerated from
protocol/plugin/src/catalog.ts - SPECIFICATION.md — API shape rules, mirror table, naming conventions, PR checklist
