Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View File

@@ -0,0 +1,161 @@
/**
* Bundling presets for Paperclip plugins.
*
* These helpers return plain config objects so plugin authors can use them
* with esbuild or rollup without re-implementing host contract defaults.
*/
export interface PluginBundlerPresetInput {
pluginRoot?: string;
manifestEntry?: string;
workerEntry?: string;
uiEntry?: string;
outdir?: string;
sourcemap?: boolean;
minify?: boolean;
}
export interface EsbuildLikeOptions {
entryPoints: string[];
outdir: string;
bundle: boolean;
format: "esm";
platform: "node" | "browser";
target: string;
sourcemap?: boolean;
minify?: boolean;
external?: string[];
}
export interface RollupLikeConfig {
input: string;
output: {
dir: string;
format: "es";
sourcemap?: boolean;
entryFileNames?: string;
};
external?: string[];
plugins?: unknown[];
}
export interface PluginBundlerPresets {
esbuild: {
worker: EsbuildLikeOptions;
ui?: EsbuildLikeOptions;
manifest: EsbuildLikeOptions;
};
rollup: {
worker: RollupLikeConfig;
ui?: RollupLikeConfig;
manifest: RollupLikeConfig;
};
}
/**
* Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
*
* The presets intentionally externalize host/runtime deps (`react`, SDK packages)
* to match the Paperclip plugin loader contract.
*/
export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
const uiExternal = [
"@paperclipai/plugin-sdk/ui",
"@paperclipai/plugin-sdk/ui/hooks",
"@paperclipai/plugin-sdk/ui/components",
"react",
"react-dom",
"react/jsx-runtime",
];
const outdir = input.outdir ?? "dist";
const workerEntry = input.workerEntry ?? "src/worker.ts";
const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
const uiEntry = input.uiEntry;
const sourcemap = input.sourcemap ?? true;
const minify = input.minify ?? false;
const esbuildWorker: EsbuildLikeOptions = {
entryPoints: [workerEntry],
outdir,
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
minify,
external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"],
};
const esbuildManifest: EsbuildLikeOptions = {
entryPoints: [manifestEntry],
outdir,
bundle: false,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
};
const esbuildUi = uiEntry
? {
entryPoints: [uiEntry],
outdir: `${outdir}/ui`,
bundle: true,
format: "esm" as const,
platform: "browser" as const,
target: "es2022",
sourcemap,
minify,
external: uiExternal,
}
: undefined;
const rollupWorker: RollupLikeConfig = {
input: workerEntry,
output: {
dir: outdir,
format: "es",
sourcemap,
entryFileNames: "worker.js",
},
external: ["@paperclipai/plugin-sdk", "react", "react-dom"],
};
const rollupManifest: RollupLikeConfig = {
input: manifestEntry,
output: {
dir: outdir,
format: "es",
sourcemap,
entryFileNames: "manifest.js",
},
external: ["@paperclipai/plugin-sdk"],
};
const rollupUi = uiEntry
? {
input: uiEntry,
output: {
dir: `${outdir}/ui`,
format: "es" as const,
sourcemap,
entryFileNames: "index.js",
},
external: uiExternal,
}
: undefined;
return {
esbuild: {
worker: esbuildWorker,
manifest: esbuildManifest,
...(esbuildUi ? { ui: esbuildUi } : {}),
},
rollup: {
worker: rollupWorker,
manifest: rollupManifest,
...(rollupUi ? { ui: rollupUi } : {}),
},
};
}

View File

@@ -0,0 +1,255 @@
/**
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
*
* Plugin authors call `definePlugin()` and export the result as the default
* export from their worker entrypoint. The host imports the worker module,
* calls `setup()` with a `PluginContext`, and from that point the plugin
* responds to events, jobs, webhooks, and UI requests through the context.
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*
* @example
* ```ts
* // dist/worker.ts
* import { definePlugin } from "@paperclipai/plugin-sdk";
*
* export default definePlugin({
* async setup(ctx) {
* ctx.logger.info("Linear sync plugin starting");
*
* // Subscribe to events
* ctx.events.on("issue.created", async (event) => {
* const config = await ctx.config.get();
* await ctx.http.fetch(`https://api.linear.app/...`, {
* method: "POST",
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
* body: JSON.stringify({ title: event.payload.title }),
* });
* });
*
* // Register a job handler
* ctx.jobs.register("full-sync", async (job) => {
* ctx.logger.info("Running full-sync job", { runId: job.runId });
* // ... sync logic
* });
*
* // Register data for the UI
* ctx.data.register("sync-health", async ({ companyId }) => {
* const state = await ctx.state.get({
* scopeKind: "company",
* scopeId: String(companyId),
* stateKey: "last-sync",
* });
* return { lastSync: state };
* });
* },
* });
* ```
*/
import type { PluginContext } from "./types.js";
// ---------------------------------------------------------------------------
// Health check result
// ---------------------------------------------------------------------------
/**
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
*
* @see PLUGIN_SPEC.md §13.2 — `health`
*/
export interface PluginHealthDiagnostics {
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
status: "ok" | "degraded" | "error";
/** Human-readable description of the current health state. */
message?: string;
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
details?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Config validation result
// ---------------------------------------------------------------------------
/**
* Result returned from the `validateConfig()` RPC method.
*
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
*/
export interface PluginConfigValidationResult {
/** Whether the config is valid. */
ok: boolean;
/** Non-fatal warnings about the config. */
warnings?: string[];
/** Validation errors (populated when `ok` is `false`). */
errors?: string[];
}
// ---------------------------------------------------------------------------
// Webhook handler input
// ---------------------------------------------------------------------------
/**
* Input received by the plugin worker's `handleWebhook` handler.
*
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
*/
export interface PluginWebhookInput {
/** Endpoint key matching the manifest declaration. */
endpointKey: string;
/** Inbound request headers. */
headers: Record<string, string | string[]>;
/** Raw request body as a UTF-8 string. */
rawBody: string;
/** Parsed JSON body (if applicable and parseable). */
parsedBody?: unknown;
/** Unique request identifier for idempotency checks. */
requestId: string;
}
// ---------------------------------------------------------------------------
// Plugin definition
// ---------------------------------------------------------------------------
/**
* The plugin definition shape passed to `definePlugin()`.
*
* The only required field is `setup`, which receives the `PluginContext` and
* is where the plugin registers its handlers (events, jobs, data, actions,
* tools, etc.).
*
* All other lifecycle hooks are optional. If a hook is not implemented the
* host applies default behaviour (e.g. restarting the worker on config change
* instead of calling `onConfigChanged`).
*
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
*/
export interface PluginDefinition {
/**
* Called once when the plugin worker starts up, after `initialize` completes.
*
* This is where the plugin registers all its handlers: event subscriptions,
* job handlers, data/action handlers, and tool registrations. Registration
* must be synchronous after `setup` resolves — do not register handlers
* inside async callbacks that may resolve after `setup` returns.
*
* @param ctx - The full plugin context provided by the host
*/
setup(ctx: PluginContext): Promise<void>;
/**
* Called when the host wants to know if the plugin is healthy.
*
* The host polls this on a regular interval and surfaces the result in the
* plugin health dashboard. If not implemented, the host infers health from
* worker process liveness.
*
* @see PLUGIN_SPEC.md §13.2 — `health`
*/
onHealth?(): Promise<PluginHealthDiagnostics>;
/**
* Called when the operator updates the plugin's instance configuration at
* runtime, without restarting the worker.
*
* If not implemented, the host restarts the worker to apply the new config.
*
* @param newConfig - The newly resolved configuration
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
*/
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
/**
* Called when the host is about to shut down the plugin worker.
*
* The worker has at most 10 seconds (configurable via plugin config) to
* finish in-flight work and resolve this promise. After the deadline the
* host sends SIGTERM, then SIGKILL.
*
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
*/
onShutdown?(): Promise<void>;
/**
* Called to validate the current plugin configuration.
*
* The host calls this:
* - after the plugin starts (to surface config errors immediately)
* - after the operator saves a new config (to validate before persisting)
* - via the "Test Connection" button in the settings UI
*
* @param config - The configuration to validate
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
*/
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
/**
* Called to handle an inbound webhook delivery.
*
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
* this handler. The plugin is responsible for signature verification using
* a resolved secret ref.
*
* If not implemented but webhooks are declared in the manifest, the host
* returns HTTP 501 for webhook deliveries.
*
* @param input - Webhook delivery metadata and payload
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
*/
onWebhook?(input: PluginWebhookInput): Promise<void>;
}
// ---------------------------------------------------------------------------
// PaperclipPlugin — the sealed object returned by definePlugin()
// ---------------------------------------------------------------------------
/**
* The sealed plugin object returned by `definePlugin()`.
*
* Plugin authors export this as the default export from their worker
* entrypoint. The host imports it and calls the lifecycle methods.
*
* @see PLUGIN_SPEC.md §14 — SDK Surface
*/
export interface PaperclipPlugin {
/** The original plugin definition passed to `definePlugin()`. */
readonly definition: PluginDefinition;
}
// ---------------------------------------------------------------------------
// definePlugin — top-level factory
// ---------------------------------------------------------------------------
/**
* Define a Paperclip plugin.
*
* Call this function in your worker entrypoint and export the result as the
* default export. The host will import the module and call lifecycle methods
* on the returned object.
*
* @param definition - Plugin lifecycle handlers
* @returns A sealed `PaperclipPlugin` object for the host to consume
*
* @example
* ```ts
* import { definePlugin } from "@paperclipai/plugin-sdk";
*
* export default definePlugin({
* async setup(ctx) {
* ctx.logger.info("Plugin started");
* ctx.events.on("issue.created", async (event) => {
* // handle event
* });
* },
*
* async onHealth() {
* return { status: "ok" };
* },
* });
* ```
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*/
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
return Object.freeze({ definition });
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import path from "node:path";
import { startPluginDevServer } from "./dev-server.js";
function parseArg(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
if (index < 0) return undefined;
return process.argv[index + 1];
}
/**
* CLI entrypoint for the local plugin UI preview server.
*
* This is intentionally minimal and delegates all serving behavior to
* `startPluginDevServer` so tests and programmatic usage share one path.
*/
async function main() {
const rootDir = parseArg("--root") ?? process.cwd();
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
const host = parseArg("--host") ?? "127.0.0.1";
const rawPort = parseArg("--port") ?? "4177";
const port = Number.parseInt(rawPort, 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid --port value: ${rawPort}`);
}
const server = await startPluginDevServer({
rootDir: path.resolve(rootDir),
uiDir,
host,
port,
});
// eslint-disable-next-line no-console
console.log(`Paperclip plugin dev server listening at ${server.url}`);
const shutdown = async () => {
await server.close();
process.exit(0);
};
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});
}
void main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -0,0 +1,228 @@
import { createReadStream, existsSync, statSync, watch } from "node:fs";
import { mkdir, readdir, stat } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import path from "node:path";
export interface PluginDevServerOptions {
/** Plugin project root. Defaults to `process.cwd()`. */
rootDir?: string;
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
uiDir?: string;
/** Bind port for local preview server. Defaults to `4177`. */
port?: number;
/** Bind host. Defaults to `127.0.0.1`. */
host?: string;
}
export interface PluginDevServer {
url: string;
close(): Promise<void>;
}
interface Closeable {
close(): void;
}
function contentType(filePath: string): string {
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
function normalizeFilePath(baseDir: string, reqPath: string): string {
const pathname = reqPath.split("?")[0] || "/";
const resolved = pathname === "/" ? "/index.js" : pathname;
const absolute = path.resolve(baseDir, `.${resolved}`);
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
throw new Error("path traversal blocked");
}
return absolute;
}
function send404(res: ServerResponse) {
res.statusCode = 404;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: "Not found" }));
}
function sendJson(res: ServerResponse, value: unknown) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(value));
}
async function ensureUiDir(uiDir: string): Promise<void> {
if (existsSync(uiDir)) return;
await mkdir(uiDir, { recursive: true });
}
async function listFilesRecursive(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...await listFilesRecursive(abs));
} else if (entry.isFile()) {
out.push(abs);
}
}
return out;
}
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
}
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
try {
// macOS/Windows support recursive native watching.
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
if (!filename) return;
onReload(path.join(uiDir, filename));
});
return watcher;
} catch {
// Linux may reject recursive watch. Fall back to polling snapshots.
let previous = snapshotSignature(
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
file: row.file,
mtimeMs: row.mtimeMs,
})),
);
const timer = setInterval(async () => {
try {
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
const next = snapshotSignature(nextRows);
if (next === previous) return;
previous = next;
onReload("__snapshot__");
} catch {
// Ignore transient read errors while bundlers are writing files.
}
}, 500);
return {
close() {
clearInterval(timer);
},
};
}
}
/**
* Start a local static server for plugin UI assets with SSE reload events.
*
* Endpoint summary:
* - `GET /__paperclip__/health` for diagnostics
* - `GET /__paperclip__/events` for hot-reload stream
* - Any other path serves files from the configured UI build directory
*/
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
const rootDir = path.resolve(options.rootDir ?? process.cwd());
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
const host = options.host ?? "127.0.0.1";
const port = options.port ?? 4177;
await ensureUiDir(uiDir);
const sseClients = new Set<ServerResponse>();
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
const url = req.url ?? "/";
if (url === "/__paperclip__/health") {
sendJson(res, { ok: true, rootDir, uiDir });
return;
}
if (url === "/__paperclip__/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
res.write(`event: connected\ndata: {"ok":true}\n\n`);
sseClients.add(res);
req.on("close", () => {
sseClients.delete(res);
});
return;
}
try {
const filePath = normalizeFilePath(uiDir, url);
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
send404(res);
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", contentType(filePath));
createReadStream(filePath).pipe(res);
} catch {
send404(res);
}
};
const server = createServer((req, res) => {
void handleRequest(req, res);
});
const notifyReload = (filePath: string) => {
const rel = path.relative(uiDir, filePath);
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
for (const client of sseClients) {
client.write(`event: reload\ndata: ${payload}\n\n`);
}
};
const watcher = await startUiWatcher(uiDir, notifyReload);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});
const address = server.address();
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
return {
url: `http://${host}:${actualPort}`,
async close() {
watcher.close();
for (const client of sseClients) {
client.end();
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
/**
* Return a stable file+mtime snapshot for a built plugin UI directory.
*
* Used by the polling watcher fallback and useful for tests that need to assert
* whether a UI build has changed between runs.
*/
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
const baseDir = path.resolve(rootDir, uiDir);
if (!existsSync(baseDir)) return [];
const files = await listFilesRecursive(baseDir);
const rows = await Promise.all(files.map(async (filePath) => {
const fileStat = await stat(filePath);
return {
file: path.relative(baseDir, filePath),
mtimeMs: fileStat.mtimeMs,
};
}));
return rows.sort((a, b) => a.file.localeCompare(b.file));
}

View File

@@ -0,0 +1,563 @@
/**
* Host-side client factory — creates capability-gated handler maps for
* servicing worker→host JSON-RPC calls.
*
* When a plugin worker calls `ctx.state.get(...)` inside its process, the
* SDK serializes the call as a JSON-RPC request over stdio. On the host side,
* the `PluginWorkerManager` receives the request and dispatches it to the
* handler registered for that method. This module provides a factory that
* creates those handlers for all `WorkerToHostMethods`, with automatic
* capability enforcement.
*
* ## Design
*
* 1. **Capability gating**: Each handler checks the plugin's declared
* capabilities before executing. If the plugin lacks a required capability,
* the handler throws a `CapabilityDeniedError` (which the worker manager
* translates into a JSON-RPC error response with code
* `CAPABILITY_DENIED`).
*
* 2. **Service adapters**: The caller provides a `HostServices` object with
* concrete implementations of each platform service. The factory wires
* each handler to the appropriate service method.
*
* 3. **Type safety**: The returned handler map is typed as
* `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
* directly into `WorkerStartOptions.hostHandlers`.
*
* @example
* ```ts
* const handlers = createHostClientHandlers({
* pluginId: "acme.linear",
* capabilities: manifest.capabilities,
* services: {
* config: { get: () => registry.getConfig(pluginId) },
* state: { get: ..., set: ..., delete: ... },
* entities: { upsert: ..., list: ... },
* // ... all services
* },
* });
*
* await workerManager.startWorker("acme.linear", {
* // ...
* hostHandlers: handlers,
* });
* ```
*
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
* @see PLUGIN_SPEC.md §15 — Capability Model
*/
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/**
* Thrown when a plugin calls a host method it does not have the capability for.
*
* The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
* the worker manager can propagate it as the correct JSON-RPC error code.
*/
export class CapabilityDeniedError extends Error {
override readonly name = "CapabilityDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
constructor(pluginId: string, method: string, capability: PluginCapability) {
super(
`Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
);
}
}
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
/**
* Service adapters that the host must provide. Each property maps to a group
* of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
* function signatures.
*
* All methods return promises to support async I/O (database, HTTP, etc.).
*/
export interface HostServices {
/** Provides `config.get`. */
config: {
get(): Promise<Record<string, unknown>>;
};
/** Provides `state.get`, `state.set`, `state.delete`. */
state: {
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
};
/** Provides `entities.upsert`, `entities.list`. */
entities: {
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
};
/** Provides `events.emit`. */
events: {
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
};
/** Provides `http.fetch`. */
http: {
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
};
/** Provides `secrets.resolve`. */
secrets: {
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
};
/** Provides `assets.upload`, `assets.getUrl`. */
assets: {
upload(params: WorkerToHostMethods["assets.upload"][0]): Promise<WorkerToHostMethods["assets.upload"][1]>;
getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise<string>;
};
/** Provides `activity.log`. */
activity: {
log(params: {
companyId: string;
message: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
}): Promise<void>;
};
/** Provides `metrics.write`. */
metrics: {
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
};
/** Provides `log`. */
logger: {
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
};
/** Provides `companies.list`, `companies.get`. */
companies: {
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
};
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
projects: {
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
};
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
issues: {
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
};
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
agents: {
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
};
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
agentSessions: {
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
};
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
goals: {
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
};
}
// ---------------------------------------------------------------------------
// Factory input
// ---------------------------------------------------------------------------
/**
* Options for `createHostClientHandlers`.
*/
export interface HostClientFactoryOptions {
/** The plugin ID. Used for error messages and logging. */
pluginId: string;
/**
* The capabilities declared by the plugin in its manifest. The factory
* enforces these at runtime before delegating to the service adapter.
*/
capabilities: readonly PluginCapability[];
/**
* Concrete implementations of host platform services. Each handler in the
* returned map delegates to the corresponding service method.
*/
services: HostServices;
}
// ---------------------------------------------------------------------------
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
// ---------------------------------------------------------------------------
/**
* A handler function for a specific worker→host method.
*/
type HostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0],
) => Promise<WorkerToHostMethods[M][1]>;
/**
* A complete map of all worker→host method handlers.
*
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
* but makes every handler required (the factory always provides all handlers).
*/
export type HostClientHandlers = {
[M in WorkerToHostMethodName]: HostHandler<M>;
};
// ---------------------------------------------------------------------------
// Capability → method mapping
// ---------------------------------------------------------------------------
/**
* Maps each worker→host RPC method to the capability required to invoke it.
* Methods without a capability requirement (e.g. `config.get`, `log`) are
* mapped to `null`.
*
* @see PLUGIN_SPEC.md §15 — Capability Model
*/
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
// Config — always allowed
"config.get": null,
// State
"state.get": "plugin.state.read",
"state.set": "plugin.state.write",
"state.delete": "plugin.state.write",
// Entities — no specific capability required (plugin-scoped by design)
"entities.upsert": null,
"entities.list": null,
// Events
"events.emit": "events.emit",
// HTTP
"http.fetch": "http.outbound",
// Secrets
"secrets.resolve": "secrets.read-ref",
// Assets
"assets.upload": "assets.write",
"assets.getUrl": "assets.read",
// Activity
"activity.log": "activity.log.write",
// Metrics
"metrics.write": "metrics.write",
// Logger — always allowed
"log": null,
// Companies
"companies.list": "companies.read",
"companies.get": "companies.read",
// Projects
"projects.list": "projects.read",
"projects.get": "projects.read",
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
// Issues
"issues.list": "issues.read",
"issues.get": "issues.read",
"issues.create": "issues.create",
"issues.update": "issues.update",
"issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create",
// Agents
"agents.list": "agents.read",
"agents.get": "agents.read",
"agents.pause": "agents.pause",
"agents.resume": "agents.resume",
"agents.invoke": "agents.invoke",
// Agent Sessions
"agents.sessions.create": "agent.sessions.create",
"agents.sessions.list": "agent.sessions.list",
"agents.sessions.sendMessage": "agent.sessions.send",
"agents.sessions.close": "agent.sessions.close",
// Goals
"goals.list": "goals.read",
"goals.get": "goals.read",
"goals.create": "goals.create",
"goals.update": "goals.update",
};
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a complete handler map for all worker→host JSON-RPC methods.
*
* Each handler:
* 1. Checks the plugin's declared capabilities against the required capability
* for the method (if any).
* 2. Delegates to the corresponding service adapter method.
* 3. Returns the service result, which is serialized as the JSON-RPC response
* by the worker manager.
*
* If a capability check fails, the handler throws a `CapabilityDeniedError`
* with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
* JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
* in the plugin's SDK client.
*
* @param options - Plugin ID, capabilities, and service adapters
* @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
*/
export function createHostClientHandlers(
options: HostClientFactoryOptions,
): HostClientHandlers {
const { pluginId, services } = options;
const capabilitySet = new Set<PluginCapability>(options.capabilities);
/**
* Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing.
*/
function requireCapability(
method: WorkerToHostMethodName,
): void {
const required = METHOD_CAPABILITY_MAP[method];
if (required === null) return; // No capability required
if (capabilitySet.has(required)) return;
throw new CapabilityDeniedError(pluginId, method, required);
}
/**
* Create a capability-gated proxy handler for a method.
*
* @param method - The RPC method name (used for capability lookup)
* @param handler - The actual handler implementation
* @returns A wrapper that checks capabilities before delegating
*/
function gated<M extends WorkerToHostMethodName>(
method: M,
handler: HostHandler<M>,
): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => {
requireCapability(method);
return handler(params);
};
}
// -------------------------------------------------------------------------
// Build the complete handler map
// -------------------------------------------------------------------------
return {
// Config
"config.get": gated("config.get", async () => {
return services.config.get();
}),
// State
"state.get": gated("state.get", async (params) => {
return services.state.get(params);
}),
"state.set": gated("state.set", async (params) => {
return services.state.set(params);
}),
"state.delete": gated("state.delete", async (params) => {
return services.state.delete(params);
}),
// Entities
"entities.upsert": gated("entities.upsert", async (params) => {
return services.entities.upsert(params);
}),
"entities.list": gated("entities.list", async (params) => {
return services.entities.list(params);
}),
// Events
"events.emit": gated("events.emit", async (params) => {
return services.events.emit(params);
}),
// HTTP
"http.fetch": gated("http.fetch", async (params) => {
return services.http.fetch(params);
}),
// Secrets
"secrets.resolve": gated("secrets.resolve", async (params) => {
return services.secrets.resolve(params);
}),
// Assets
"assets.upload": gated("assets.upload", async (params) => {
return services.assets.upload(params);
}),
"assets.getUrl": gated("assets.getUrl", async (params) => {
return services.assets.getUrl(params);
}),
// Activity
"activity.log": gated("activity.log", async (params) => {
return services.activity.log(params);
}),
// Metrics
"metrics.write": gated("metrics.write", async (params) => {
return services.metrics.write(params);
}),
// Logger
"log": gated("log", async (params) => {
return services.logger.log(params);
}),
// Companies
"companies.list": gated("companies.list", async (params) => {
return services.companies.list(params);
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
}),
// Projects
"projects.list": gated("projects.list", async (params) => {
return services.projects.list(params);
}),
"projects.get": gated("projects.get", async (params) => {
return services.projects.get(params);
}),
"projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
return services.projects.listWorkspaces(params);
}),
"projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
return services.projects.getPrimaryWorkspace(params);
}),
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
return services.projects.getWorkspaceForIssue(params);
}),
// Issues
"issues.list": gated("issues.list", async (params) => {
return services.issues.list(params);
}),
"issues.get": gated("issues.get", async (params) => {
return services.issues.get(params);
}),
"issues.create": gated("issues.create", async (params) => {
return services.issues.create(params);
}),
"issues.update": gated("issues.update", async (params) => {
return services.issues.update(params);
}),
"issues.listComments": gated("issues.listComments", async (params) => {
return services.issues.listComments(params);
}),
"issues.createComment": gated("issues.createComment", async (params) => {
return services.issues.createComment(params);
}),
// Agents
"agents.list": gated("agents.list", async (params) => {
return services.agents.list(params);
}),
"agents.get": gated("agents.get", async (params) => {
return services.agents.get(params);
}),
"agents.pause": gated("agents.pause", async (params) => {
return services.agents.pause(params);
}),
"agents.resume": gated("agents.resume", async (params) => {
return services.agents.resume(params);
}),
"agents.invoke": gated("agents.invoke", async (params) => {
return services.agents.invoke(params);
}),
// Agent Sessions
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
return services.agentSessions.create(params);
}),
"agents.sessions.list": gated("agents.sessions.list", async (params) => {
return services.agentSessions.list(params);
}),
"agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
return services.agentSessions.sendMessage(params);
}),
"agents.sessions.close": gated("agents.sessions.close", async (params) => {
return services.agentSessions.close(params);
}),
// Goals
"goals.list": gated("goals.list", async (params) => {
return services.goals.list(params);
}),
"goals.get": gated("goals.get", async (params) => {
return services.goals.get(params);
}),
"goals.create": gated("goals.create", async (params) => {
return services.goals.create(params);
}),
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
};
}
// ---------------------------------------------------------------------------
// Utility: getRequiredCapability
// ---------------------------------------------------------------------------
/**
* Get the capability required for a given worker→host method, or `null` if
* no capability is required.
*
* Useful for inspecting capability requirements without calling the factory.
*
* @param method - The worker→host method name
* @returns The required capability, or `null`
*/
export function getRequiredCapability(
method: WorkerToHostMethodName,
): PluginCapability | null {
return METHOD_CAPABILITY_MAP[method];
}

View File

@@ -0,0 +1,287 @@
/**
* `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
*
* This is the main entrypoint for plugin worker code. For plugin UI bundles,
* import from `@paperclipai/plugin-sdk/ui` instead.
*
* @example
* ```ts
* // Plugin worker entrypoint (dist/worker.ts)
* import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
*
* const plugin = definePlugin({
* async setup(ctx) {
* ctx.logger.info("Plugin starting up");
*
* ctx.events.on("issue.created", async (event) => {
* ctx.logger.info("Issue created", { issueId: event.entityId });
* });
*
* ctx.jobs.register("full-sync", async (job) => {
* ctx.logger.info("Starting full sync", { runId: job.runId });
* // ... sync implementation
* });
*
* ctx.data.register("sync-health", async ({ companyId }) => {
* const state = await ctx.state.get({
* scopeKind: "company",
* scopeId: String(companyId),
* stateKey: "last-sync-at",
* });
* return { lastSync: state };
* });
* },
*
* async onHealth() {
* return { status: "ok" };
* },
* });
*
* export default plugin;
* runWorker(plugin, import.meta.url);
* ```
*
* @see PLUGIN_SPEC.md §14 — SDK Surface
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
*/
// ---------------------------------------------------------------------------
// Main factory
// ---------------------------------------------------------------------------
export { definePlugin } from "./define-plugin.js";
export { createTestHarness } from "./testing.js";
export { createPluginBundlerPresets } from "./bundlers.js";
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
export {
JSONRPC_VERSION,
JSONRPC_ERROR_CODES,
PLUGIN_RPC_ERROR_CODES,
HOST_TO_WORKER_REQUIRED_METHODS,
HOST_TO_WORKER_OPTIONAL_METHODS,
MESSAGE_DELIMITER,
createRequest,
createSuccessResponse,
createErrorResponse,
createNotification,
isJsonRpcRequest,
isJsonRpcNotification,
isJsonRpcResponse,
isJsonRpcSuccessResponse,
isJsonRpcErrorResponse,
serializeMessage,
parseMessage,
JsonRpcParseError,
JsonRpcCallError,
_resetIdCounter,
} from "./protocol.js";
// ---------------------------------------------------------------------------
// Type exports
// ---------------------------------------------------------------------------
// Plugin definition and lifecycle types
export type {
PluginDefinition,
PaperclipPlugin,
PluginHealthDiagnostics,
PluginConfigValidationResult,
PluginWebhookInput,
} from "./define-plugin.js";
export type {
TestHarness,
TestHarnessOptions,
TestHarnessLogEntry,
} from "./testing.js";
export type {
PluginBundlerPresetInput,
PluginBundlerPresets,
EsbuildLikeOptions,
RollupLikeConfig,
} from "./bundlers.js";
export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
export type {
WorkerRpcHostOptions,
WorkerRpcHost,
RunWorkerOptions,
} from "./worker-rpc-host.js";
export type {
HostServices,
HostClientFactoryOptions,
HostClientHandlers,
} from "./host-client-factory.js";
// JSON-RPC protocol types
export type {
JsonRpcId,
JsonRpcRequest,
JsonRpcSuccessResponse,
JsonRpcError,
JsonRpcErrorResponse,
JsonRpcResponse,
JsonRpcNotification,
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
InitializeParams,
InitializeResult,
ConfigChangedParams,
ValidateConfigParams,
OnEventParams,
RunJobParams,
GetDataParams,
PerformActionParams,
ExecuteToolParams,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
PluginLauncherRenderContextSnapshot,
HostToWorkerMethods,
HostToWorkerMethodName,
WorkerToHostMethods,
WorkerToHostMethodName,
HostToWorkerRequest,
HostToWorkerResponse,
WorkerToHostRequest,
WorkerToHostResponse,
WorkerToHostNotifications,
WorkerToHostNotificationName,
} from "./protocol.js";
// Plugin context and all client interfaces
export type {
PluginContext,
PluginConfigClient,
PluginEventsClient,
PluginJobsClient,
PluginLaunchersClient,
PluginHttpClient,
PluginSecretsClient,
PluginAssetsClient,
PluginActivityClient,
PluginActivityLogEntry,
PluginStateClient,
PluginEntitiesClient,
PluginProjectsClient,
PluginCompaniesClient,
PluginIssuesClient,
PluginAgentsClient,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
AgentSessionSendResult,
PluginGoalsClient,
PluginDataClient,
PluginActionsClient,
PluginStreamsClient,
PluginToolsClient,
PluginMetricsClient,
PluginLogger,
} from "./types.js";
// Supporting types for context clients
export type {
ScopeKey,
EventFilter,
PluginEvent,
PluginJobContext,
PluginLauncherRegistration,
ToolRunContext,
ToolResult,
PluginEntityUpsert,
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
Company,
Project,
Issue,
IssueComment,
Agent,
Goal,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
// Plugin authors import manifest types from here so they have a single
// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
export type {
PaperclipPluginManifestV1,
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginRecord,
PluginConfig,
JsonSchema,
PluginStatus,
PluginCategory,
PluginCapability,
PluginUiSlotType,
PluginUiSlotEntityType,
PluginLauncherPlacementZone,
PluginLauncherAction,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
PluginStateScopeKind,
PluginJobStatus,
PluginJobRunStatus,
PluginJobRunTrigger,
PluginWebhookDeliveryStatus,
PluginEventType,
PluginBridgeErrorCode,
} from "./types.js";
// ---------------------------------------------------------------------------
// Zod re-export
// ---------------------------------------------------------------------------
/**
* Zod is re-exported for plugin authors to use when defining their
* `instanceConfigSchema` and tool `parametersSchema`.
*
* Plugin authors do not need to add a separate `zod` dependency.
*
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
*
* @example
* ```ts
* import { z } from "@paperclipai/plugin-sdk";
*
* const configSchema = z.object({
* apiKey: z.string().describe("Your API key"),
* workspace: z.string().optional(),
* });
* ```
*/
export { z } from "zod";
// ---------------------------------------------------------------------------
// Constants re-exports (for plugin code that needs to check values at runtime)
// ---------------------------------------------------------------------------
export {
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
PLUGIN_JOB_RUN_TRIGGERS,
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
} from "@paperclipai/shared";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,720 @@
import { randomUUID } from "node:crypto";
import type {
PaperclipPluginManifestV1,
PluginCapability,
PluginEventType,
Company,
Project,
Issue,
IssueComment,
Agent,
Goal,
} from "@paperclipai/shared";
import type {
EventFilter,
PluginContext,
PluginEntityRecord,
PluginEntityUpsert,
PluginJobContext,
PluginLauncherRegistration,
PluginEvent,
ScopeKey,
ToolResult,
ToolRunContext,
PluginWorkspace,
AgentSession,
AgentSessionEvent,
} from "./types.js";
export interface TestHarnessOptions {
/** Plugin manifest used to seed capability checks and metadata. */
manifest: PaperclipPluginManifestV1;
/** Optional capability override. Defaults to `manifest.capabilities`. */
capabilities?: PluginCapability[];
/** Initial config returned by `ctx.config.get()`. */
config?: Record<string, unknown>;
}
export interface TestHarnessLogEntry {
level: "info" | "warn" | "error" | "debug";
message: string;
meta?: Record<string, unknown>;
}
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
issues?: Issue[];
issueComments?: IssueComment[];
agents?: Agent[];
goals?: Goal[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
/** Execute a previously-registered scheduled job handler. */
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
/** Invoke a `ctx.data.register(...)` handler by key. */
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
/** Invoke a `ctx.actions.register(...)` handler by key. */
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
/** Read raw in-memory state for assertions. */
getState(input: ScopeKey): unknown;
/** Simulate a streaming event arriving for an active session. */
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
logs: TestHarnessLogEntry[];
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
}
type EventRegistration = {
name: PluginEventType | `plugin.${string}`;
filter?: EventFilter;
fn: (event: PluginEvent) => Promise<void>;
};
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
return {
scopeKind: input.scopeKind,
scopeId: input.scopeId,
namespace: input.namespace ?? "default",
stateKey: input.stateKey,
};
}
function stateMapKey(input: ScopeKey): string {
const normalized = normalizeScope(input);
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
}
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
if (!filter) return true;
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
return true;
}
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
if (allowed.has(capability)) return;
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
}
function requireCompanyId(companyId?: string): string {
if (!companyId) throw new Error("companyId is required for this operation");
return companyId;
}
function isInCompany<T extends { companyId: string | null | undefined }>(
record: T | null | undefined,
companyId: string,
): record is T {
return Boolean(record && record.companyId === companyId);
}
/**
* Create an in-memory host harness for plugin worker tests.
*
* The harness enforces declared capabilities and simulates host APIs, so tests
* can validate plugin behavior without spinning up the Paperclip server runtime.
*/
export function createTestHarness(options: TestHarnessOptions): TestHarness {
const manifest = options.manifest;
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
let currentConfig = { ...(options.config ?? {}) };
const logs: TestHarnessLogEntry[] = [];
const activity: TestHarness["activity"] = [];
const metrics: TestHarness["metrics"] = [];
const state = new Map<string, unknown>();
const entities = new Map<string, PluginEntityRecord>();
const entityExternalIndex = new Map<string, string>();
const assets = new Map<string, { contentType: string; data: Uint8Array }>();
const companies = new Map<string, Company>();
const projects = new Map<string, Project>();
const issues = new Map<string, Issue>();
const issueComments = new Map<string, IssueComment[]>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const sessions = new Map<string, AgentSession>();
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
const events: EventRegistration[] = [];
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launchers = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
const ctx: PluginContext = {
manifest,
config: {
async get() {
return { ...currentConfig };
},
},
events: {
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
requireCapability(manifest, capabilitySet, "events.subscribe");
let registration: EventRegistration;
if (typeof filterOrFn === "function") {
registration = { name, fn: filterOrFn };
} else {
if (!maybeFn) throw new Error("event handler is required");
registration = { name, filter: filterOrFn, fn: maybeFn };
}
events.push(registration);
return () => {
const idx = events.indexOf(registration);
if (idx !== -1) events.splice(idx, 1);
};
},
async emit(name, companyId, payload) {
requireCapability(manifest, capabilitySet, "events.emit");
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
},
},
jobs: {
register(key, fn) {
requireCapability(manifest, capabilitySet, "jobs.schedule");
jobs.set(key, fn);
},
},
launchers: {
register(launcher) {
launchers.set(launcher.id, launcher);
},
},
http: {
async fetch(url, init) {
requireCapability(manifest, capabilitySet, "http.outbound");
return fetch(url, init);
},
},
secrets: {
async resolve(secretRef) {
requireCapability(manifest, capabilitySet, "secrets.read-ref");
return `resolved:${secretRef}`;
},
},
assets: {
async upload(filename, contentType, data) {
requireCapability(manifest, capabilitySet, "assets.write");
const assetId = `asset_${randomUUID()}`;
assets.set(assetId, { contentType, data: data instanceof Uint8Array ? data : new Uint8Array(data) });
return { assetId, url: `memory://assets/${filename}` };
},
async getUrl(assetId) {
requireCapability(manifest, capabilitySet, "assets.read");
if (!assets.has(assetId)) throw new Error(`Asset not found: ${assetId}`);
return `memory://assets/${assetId}`;
},
},
activity: {
async log(entry) {
requireCapability(manifest, capabilitySet, "activity.log.write");
activity.push(entry);
},
},
state: {
async get(input) {
requireCapability(manifest, capabilitySet, "plugin.state.read");
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
},
async set(input, value) {
requireCapability(manifest, capabilitySet, "plugin.state.write");
state.set(stateMapKey(input), value);
},
async delete(input) {
requireCapability(manifest, capabilitySet, "plugin.state.write");
state.delete(stateMapKey(input));
},
},
entities: {
async upsert(input: PluginEntityUpsert) {
const externalKey = input.externalId
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
: null;
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
const existing = existingId ? entities.get(existingId) : undefined;
const now = new Date().toISOString();
const previousExternalKey = existing?.externalId
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
: null;
const record: PluginEntityRecord = existing
? {
...existing,
entityType: input.entityType,
scopeKind: input.scopeKind,
scopeId: input.scopeId ?? null,
externalId: input.externalId ?? null,
title: input.title ?? null,
status: input.status ?? null,
data: input.data,
updatedAt: now,
}
: {
id: randomUUID(),
entityType: input.entityType,
scopeKind: input.scopeKind,
scopeId: input.scopeId ?? null,
externalId: input.externalId ?? null,
title: input.title ?? null,
status: input.status ?? null,
data: input.data,
createdAt: now,
updatedAt: now,
};
entities.set(record.id, record);
if (previousExternalKey && previousExternalKey !== externalKey) {
entityExternalIndex.delete(previousExternalKey);
}
if (externalKey) entityExternalIndex.set(externalKey, record.id);
return record;
},
async list(query) {
let out = [...entities.values()];
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
if (query.offset) out = out.slice(query.offset);
if (query.limit) out = out.slice(0, query.limit);
return out;
},
},
projects: {
async list(input) {
requireCapability(manifest, capabilitySet, "projects.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...projects.values()];
out = out.filter((project) => project.companyId === companyId);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(projectId, companyId) {
requireCapability(manifest, capabilitySet, "projects.read");
const project = projects.get(projectId);
return isInCompany(project, companyId) ? project : null;
},
async listWorkspaces(projectId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
if (!isInCompany(projects.get(projectId), companyId)) return [];
return projectWorkspaces.get(projectId) ?? [];
},
async getPrimaryWorkspace(projectId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
if (!isInCompany(projects.get(projectId), companyId)) return null;
const workspaces = projectWorkspaces.get(projectId) ?? [];
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
},
async getWorkspaceForIssue(issueId, companyId) {
requireCapability(manifest, capabilitySet, "project.workspaces.read");
const issue = issues.get(issueId);
if (!isInCompany(issue, companyId)) return null;
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
if (!projectId) return null;
if (!isInCompany(projects.get(projectId), companyId)) return null;
const workspaces = projectWorkspaces.get(projectId) ?? [];
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
},
},
companies: {
async list(input) {
requireCapability(manifest, capabilitySet, "companies.read");
let out = [...companies.values()];
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(companyId) {
requireCapability(manifest, capabilitySet, "companies.read");
return companies.get(companyId) ?? null;
},
},
issues: {
async list(input) {
requireCapability(manifest, capabilitySet, "issues.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...issues.values()];
out = out.filter((issue) => issue.companyId === companyId);
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
if (input?.status) out = out.filter((issue) => issue.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issues.read");
const issue = issues.get(issueId);
return isInCompany(issue, companyId) ? issue : null;
},
async create(input) {
requireCapability(manifest, capabilitySet, "issues.create");
const now = new Date();
const record: Issue = {
id: randomUUID(),
companyId: input.companyId,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
parentId: input.parentId ?? null,
title: input.title,
description: input.description ?? null,
status: "todo",
priority: input.priority ?? "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: null,
identifier: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: now,
updatedAt: now,
};
issues.set(record.id, record);
return record;
},
async update(issueId, patch, companyId) {
requireCapability(manifest, capabilitySet, "issues.update");
const record = issues.get(issueId);
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
const updated: Issue = {
...record,
...patch,
updatedAt: new Date(),
};
issues.set(issueId, updated);
return updated;
},
async listComments(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.comments.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
return issueComments.get(issueId) ?? [];
},
async createComment(issueId, body, companyId) {
requireCapability(manifest, capabilitySet, "issue.comments.create");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
const now = new Date();
const comment: IssueComment = {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorAgentId: null,
authorUserId: null,
body,
createdAt: now,
updatedAt: now,
};
const current = issueComments.get(issueId) ?? [];
current.push(comment);
issueComments.set(issueId, current);
return comment;
},
},
agents: {
async list(input) {
requireCapability(manifest, capabilitySet, "agents.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...agents.values()];
out = out.filter((agent) => agent.companyId === companyId);
if (input?.status) out = out.filter((agent) => agent.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.read");
const agent = agents.get(agentId);
return isInCompany(agent, companyId) ? agent : null;
},
async pause(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.pause");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
agents.set(agentId, updated);
return updated;
},
async resume(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agents.resume");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
agents.set(agentId, updated);
return updated;
},
async invoke(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agents.invoke");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
if (
agent!.status === "paused" ||
agent!.status === "terminated" ||
agent!.status === "pending_approval"
) {
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
}
return { runId: randomUUID() };
},
sessions: {
async create(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.create");
const cid = requireCompanyId(companyId);
const agent = agents.get(agentId);
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
const session: AgentSession = {
sessionId: randomUUID(),
agentId,
companyId: cid,
status: "active",
createdAt: new Date().toISOString(),
};
sessions.set(session.sessionId, session);
return session;
},
async list(agentId, companyId) {
requireCapability(manifest, capabilitySet, "agent.sessions.list");
const cid = requireCompanyId(companyId);
return [...sessions.values()].filter(
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
);
},
async sendMessage(sessionId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.send");
const session = sessions.get(sessionId);
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
if (opts.onEvent) {
sessionEventCallbacks.set(sessionId, opts.onEvent);
}
return { runId: randomUUID() };
},
async close(sessionId, companyId) {
requireCapability(manifest, capabilitySet, "agent.sessions.close");
const session = sessions.get(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
session.status = "closed";
sessionEventCallbacks.delete(sessionId);
},
},
},
goals: {
async list(input) {
requireCapability(manifest, capabilitySet, "goals.read");
const companyId = requireCompanyId(input?.companyId);
let out = [...goals.values()];
out = out.filter((goal) => goal.companyId === companyId);
if (input?.level) out = out.filter((goal) => goal.level === input.level);
if (input?.status) out = out.filter((goal) => goal.status === input.status);
if (input?.offset) out = out.slice(input.offset);
if (input?.limit) out = out.slice(0, input.limit);
return out;
},
async get(goalId, companyId) {
requireCapability(manifest, capabilitySet, "goals.read");
const goal = goals.get(goalId);
return isInCompany(goal, companyId) ? goal : null;
},
async create(input) {
requireCapability(manifest, capabilitySet, "goals.create");
const now = new Date();
const record: Goal = {
id: randomUUID(),
companyId: input.companyId,
title: input.title,
description: input.description ?? null,
level: input.level ?? "task",
status: input.status ?? "planned",
parentId: input.parentId ?? null,
ownerAgentId: input.ownerAgentId ?? null,
createdAt: now,
updatedAt: now,
};
goals.set(record.id, record);
return record;
},
async update(goalId, patch, companyId) {
requireCapability(manifest, capabilitySet, "goals.update");
const record = goals.get(goalId);
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
const updated: Goal = {
...record,
...patch,
updatedAt: new Date(),
};
goals.set(goalId, updated);
return updated;
},
},
data: {
register(key, handler) {
dataHandlers.set(key, handler);
},
},
actions: {
register(key, handler) {
actionHandlers.set(key, handler);
},
},
streams: (() => {
const channelCompanyMap = new Map<string, string>();
return {
open(channel: string, companyId: string) {
channelCompanyMap.set(channel, companyId);
},
emit(_channel: string, _event: unknown) {
// No-op in test harness — events are not forwarded
},
close(channel: string) {
channelCompanyMap.delete(channel);
},
};
})(),
tools: {
register(name, _decl, fn) {
requireCapability(manifest, capabilitySet, "agent.tools.register");
toolHandlers.set(name, fn);
},
},
metrics: {
async write(name, value, tags) {
requireCapability(manifest, capabilitySet, "metrics.write");
metrics.push({ name, value, tags });
},
},
logger: {
info(message, meta) {
logs.push({ level: "info", message, meta });
},
warn(message, meta) {
logs.push({ level: "warn", message, meta });
},
error(message, meta) {
logs.push({ level: "error", message, meta });
},
debug(message, meta) {
logs.push({ level: "debug", message, meta });
},
},
};
const harness: TestHarness = {
ctx,
seed(input) {
for (const row of input.companies ?? []) companies.set(row.id, row);
for (const row of input.projects ?? []) projects.set(row.id, row);
for (const row of input.issues ?? []) issues.set(row.id, row);
for (const row of input.issueComments ?? []) {
const list = issueComments.get(row.issueId) ?? [];
list.push(row);
issueComments.set(row.issueId, list);
}
for (const row of input.agents ?? []) agents.set(row.id, row);
for (const row of input.goals ?? []) goals.set(row.id, row);
},
setConfig(config) {
currentConfig = { ...config };
},
async emit(eventType, payload, base) {
const event: PluginEvent = {
eventId: base?.eventId ?? randomUUID(),
eventType,
companyId: base?.companyId ?? "test-company",
occurredAt: base?.occurredAt ?? new Date().toISOString(),
actorId: base?.actorId,
actorType: base?.actorType,
entityId: base?.entityId,
entityType: base?.entityType,
payload,
};
for (const handler of events) {
const exactMatch = handler.name === event.eventType;
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
const wildcardPluginOne = String(handler.name).endsWith(".*")
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
if (!allowsEvent(handler.filter, event)) continue;
await handler.fn(event);
}
},
async runJob(jobKey, partial = {}) {
const handler = jobs.get(jobKey);
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
await handler({
jobKey,
runId: partial.runId ?? randomUUID(),
trigger: partial.trigger ?? "manual",
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
});
},
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
const handler = dataHandlers.get(key);
if (!handler) throw new Error(`No data handler registered for '${key}'`);
return await handler(params) as T;
},
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
const handler = actionHandlers.get(key);
if (!handler) throw new Error(`No action handler registered for '${key}'`);
return await handler(params) as T;
},
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
const handler = toolHandlers.get(name);
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
const ctxToPass: ToolRunContext = {
agentId: runCtx.agentId ?? "agent-test",
runId: runCtx.runId ?? randomUUID(),
companyId: runCtx.companyId ?? "company-test",
projectId: runCtx.projectId ?? "project-test",
};
return await handler(params, ctxToPass) as T;
},
getState(input) {
return state.get(stateMapKey(input));
},
simulateSessionEvent(sessionId, event) {
const cb = sessionEventCallbacks.get(sessionId);
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
cb({ ...event, sessionId });
},
logs,
activity,
metrics,
};
return harness;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
/**
* Shared UI component declarations for plugin frontends.
*
* These components are exported from `@paperclipai/plugin-sdk/ui` and are
* provided by the host at runtime. They match the host's design tokens and
* visual language, reducing the boilerplate needed to build consistent plugin UIs.
*
* **Plugins are not required to use these components.** They exist to reduce
* boilerplate and keep visual consistency. A plugin may render entirely custom
* UI using any React component library.
*
* Component implementations are provided by the host — plugin bundles contain
* only the type declarations; the runtime implementations are injected via the
* host module registry.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
*/
import type React from "react";
import { renderSdkUiComponent } from "./runtime.js";
// ---------------------------------------------------------------------------
// Component prop interfaces
// ---------------------------------------------------------------------------
/**
* A trend value that can accompany a metric.
* Positive values indicate upward trends; negative values indicate downward trends.
*/
export interface MetricTrend {
/** Direction of the trend. */
direction: "up" | "down" | "flat";
/** Percentage change value (e.g. `12.5` for 12.5%). */
percentage?: number;
}
/** Props for `MetricCard`. */
export interface MetricCardProps {
/** Short label describing the metric (e.g. `"Synced Issues"`). */
label: string;
/** The metric value to display. */
value: number | string;
/** Optional trend indicator. */
trend?: MetricTrend;
/** Optional sparkline data (array of numbers, latest last). */
sparkline?: number[];
/** Optional unit suffix (e.g. `"%"`, `"ms"`). */
unit?: string;
}
/** Status variants for `StatusBadge`. */
export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
/** Props for `StatusBadge`. */
export interface StatusBadgeProps {
/** Human-readable label. */
label: string;
/** Visual variant determining colour. */
status: StatusBadgeVariant;
}
/** A single column definition for `DataTable`. */
export interface DataTableColumn<T = Record<string, unknown>> {
/** Column key, matching a field on the row object. */
key: keyof T & string;
/** Column header label. */
header: string;
/** Optional custom cell renderer. */
render?: (value: unknown, row: T) => React.ReactNode;
/** Whether this column is sortable. */
sortable?: boolean;
/** CSS width (e.g. `"120px"`, `"20%"`). */
width?: string;
}
/** Props for `DataTable`. */
export interface DataTableProps<T = Record<string, unknown>> {
/** Column definitions. */
columns: DataTableColumn<T>[];
/** Row data. Each row should have a stable `id` field. */
rows: T[];
/** Whether the table is currently loading. */
loading?: boolean;
/** Message shown when `rows` is empty. */
emptyMessage?: string;
/** Total row count for pagination (if different from `rows.length`). */
totalCount?: number;
/** Current page (0-based, for pagination). */
page?: number;
/** Rows per page (for pagination). */
pageSize?: number;
/** Callback when page changes. */
onPageChange?: (page: number) => void;
/** Callback when a column header is clicked to sort. */
onSort?: (key: string, direction: "asc" | "desc") => void;
}
/** A single data point for `TimeseriesChart`. */
export interface TimeseriesDataPoint {
/** ISO 8601 timestamp. */
timestamp: string;
/** Numeric value. */
value: number;
/** Optional label for the point. */
label?: string;
}
/** Props for `TimeseriesChart`. */
export interface TimeseriesChartProps {
/** Series data. */
data: TimeseriesDataPoint[];
/** Chart title. */
title?: string;
/** Y-axis label. */
yLabel?: string;
/** Chart type. Defaults to `"line"`. */
type?: "line" | "bar";
/** Height of the chart in pixels. Defaults to `200`. */
height?: number;
/** Whether the chart is currently loading. */
loading?: boolean;
}
/** Props for `MarkdownBlock`. */
export interface MarkdownBlockProps {
/** Markdown content to render. */
content: string;
}
/** A single key-value pair for `KeyValueList`. */
export interface KeyValuePair {
/** Label for the key. */
label: string;
/** Value to display. May be a string, number, or a React node. */
value: React.ReactNode;
}
/** Props for `KeyValueList`. */
export interface KeyValueListProps {
/** Pairs to render in the list. */
pairs: KeyValuePair[];
}
/** A single action button for `ActionBar`. */
export interface ActionBarItem {
/** Button label. */
label: string;
/** Action key to call via the plugin bridge. */
actionKey: string;
/** Optional parameters to pass to the action handler. */
params?: Record<string, unknown>;
/** Button variant. Defaults to `"default"`. */
variant?: "default" | "primary" | "destructive";
/** Whether to show a confirmation dialog before executing. */
confirm?: boolean;
/** Text for the confirmation dialog (used when `confirm` is true). */
confirmMessage?: string;
}
/** Props for `ActionBar`. */
export interface ActionBarProps {
/** Action definitions. */
actions: ActionBarItem[];
/** Called after an action succeeds. Use to trigger data refresh. */
onSuccess?: (actionKey: string, result: unknown) => void;
/** Called when an action fails. */
onError?: (actionKey: string, error: unknown) => void;
}
/** A single log line for `LogView`. */
export interface LogViewEntry {
/** ISO 8601 timestamp. */
timestamp: string;
/** Log level. */
level: "info" | "warn" | "error" | "debug";
/** Log message. */
message: string;
/** Optional structured metadata. */
meta?: Record<string, unknown>;
}
/** Props for `LogView`. */
export interface LogViewProps {
/** Log entries to display. */
entries: LogViewEntry[];
/** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
maxHeight?: string;
/** Whether to auto-scroll to the latest entry. */
autoScroll?: boolean;
/** Whether the log is currently loading. */
loading?: boolean;
}
/** Props for `JsonTree`. */
export interface JsonTreeProps {
/** The data to render as a collapsible JSON tree. */
data: unknown;
/** Initial depth to expand. Defaults to `2`. */
defaultExpandDepth?: number;
}
/** Props for `Spinner`. */
export interface SpinnerProps {
/** Size of the spinner. Defaults to `"md"`. */
size?: "sm" | "md" | "lg";
/** Accessible label for the spinner (used as `aria-label`). */
label?: string;
}
/** Props for `ErrorBoundary`. */
export interface ErrorBoundaryProps {
/** Content to render inside the error boundary. */
children: React.ReactNode;
/** Optional custom fallback to render when an error is caught. */
fallback?: React.ReactNode;
/** Called when an error is caught, for logging or reporting. */
onError?: (error: Error, info: React.ErrorInfo) => void;
}
// ---------------------------------------------------------------------------
// Component declarations (provided by host at runtime)
// ---------------------------------------------------------------------------
// These are declared as ambient values so plugin TypeScript code can import
// and use them with full type-checking. The host's module registry provides
// the concrete React component implementations at bundle load time.
/**
* Displays a single metric with an optional trend indicator and sparkline.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
function createSdkUiComponent<TProps>(name: string): React.ComponentType<TProps> {
return function PaperclipSdkUiComponent(props: TProps) {
return renderSdkUiComponent(name, props) as React.ReactNode;
};
}
export const MetricCard = createSdkUiComponent<MetricCardProps>("MetricCard");
/**
* Displays an inline status badge (ok / warning / error / info / pending).
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const StatusBadge = createSdkUiComponent<StatusBadgeProps>("StatusBadge");
/**
* Sortable, paginated data table.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const DataTable = createSdkUiComponent<DataTableProps>("DataTable");
/**
* Line or bar chart for time-series data.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("TimeseriesChart");
/**
* Renders Markdown text as HTML.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
/**
* Renders a definition-list of label/value pairs.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const KeyValueList = createSdkUiComponent<KeyValueListProps>("KeyValueList");
/**
* Row of action buttons wired to the plugin bridge's `performAction` handlers.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const ActionBar = createSdkUiComponent<ActionBarProps>("ActionBar");
/**
* Scrollable, timestamped log output viewer.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const LogView = createSdkUiComponent<LogViewProps>("LogView");
/**
* Collapsible JSON tree for debugging or raw data inspection.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const JsonTree = createSdkUiComponent<JsonTreeProps>("JsonTree");
/**
* Loading indicator.
*
* @see PLUGIN_SPEC.md §19.6 — Shared Components
*/
export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
/**
* React error boundary that prevents plugin rendering errors from crashing
* the host page.
*
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");

View File

@@ -0,0 +1,153 @@
import type { PluginDataResult, PluginActionFn, PluginHostContext, PluginStreamResult } from "./types.js";
import { getSdkUiRuntimeValue } from "./runtime.js";
// ---------------------------------------------------------------------------
// usePluginData
// ---------------------------------------------------------------------------
/**
* Fetch data from the plugin worker's registered `getData` handler.
*
* Calls `ctx.data.register(key, handler)` in the worker and returns the
* result as reactive state. Re-fetches when `params` changes.
*
* @template T The expected shape of the returned data
* @param key - The data key matching the handler registered with `ctx.data.register()`
* @param params - Optional parameters forwarded to the handler
* @returns `PluginDataResult<T>` with `data`, `loading`, `error`, and `refresh`
*
* @example
* ```tsx
* function SyncWidget({ context }: PluginWidgetProps) {
* const { data, loading, error } = usePluginData<SyncHealth>("sync-health", {
* companyId: context.companyId,
* });
*
* if (loading) return <Spinner />;
* if (error) return <div>Error: {error.message}</div>;
* return <MetricCard label="Synced Issues" value={data!.syncedCount} />;
* }
* ```
*
* @see PLUGIN_SPEC.md §13.8 — `getData`
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
export function usePluginData<T = unknown>(
key: string,
params?: Record<string, unknown>,
): PluginDataResult<T> {
const impl = getSdkUiRuntimeValue<
(nextKey: string, nextParams?: Record<string, unknown>) => PluginDataResult<T>
>("usePluginData");
return impl(key, params);
}
// ---------------------------------------------------------------------------
// usePluginAction
// ---------------------------------------------------------------------------
/**
* Get a callable function that invokes the plugin worker's registered
* `performAction` handler.
*
* The returned function is async and throws a `PluginBridgeError` on failure.
*
* @param key - The action key matching the handler registered with `ctx.actions.register()`
* @returns An async function that sends the action to the worker and resolves with the result
*
* @example
* ```tsx
* function ResyncButton({ context }: PluginWidgetProps) {
* const resync = usePluginAction("resync");
* const [error, setError] = useState<string | null>(null);
*
* async function handleClick() {
* try {
* await resync({ companyId: context.companyId });
* } catch (err) {
* setError((err as PluginBridgeError).message);
* }
* }
*
* return <button onClick={handleClick}>Resync Now</button>;
* }
* ```
*
* @see PLUGIN_SPEC.md §13.9 — `performAction`
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
export function usePluginAction(key: string): PluginActionFn {
const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction");
return impl(key);
}
// ---------------------------------------------------------------------------
// useHostContext
// ---------------------------------------------------------------------------
/**
* Read the current host context (active company, project, entity, user).
*
* Use this to know which context the plugin component is being rendered in
* so you can scope data requests and actions accordingly.
*
* @returns The current `PluginHostContext`
*
* @example
* ```tsx
* function IssueTab() {
* const { companyId, entityId } = useHostContext();
* const { data } = usePluginData("linear-link", { issueId: entityId });
* return <div>{data?.linearIssueUrl}</div>;
* }
* ```
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
*/
export function useHostContext(): PluginHostContext {
const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext");
return impl();
}
// ---------------------------------------------------------------------------
// usePluginStream
// ---------------------------------------------------------------------------
/**
* Subscribe to a real-time event stream pushed from the plugin worker.
*
* Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel`
* and accumulates events as they arrive. The worker pushes events using
* `ctx.streams.emit(channel, event)`.
*
* @template T The expected shape of each streamed event
* @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`)
* @param options - Optional configuration for the stream
* @returns `PluginStreamResult<T>` with `events`, `lastEvent`, connection status, and `close()`
*
* @example
* ```tsx
* function ChatMessages() {
* const { events, connected, close } = usePluginStream<ChatToken>("chat-stream");
*
* return (
* <div>
* {events.map((e, i) => <span key={i}>{e.text}</span>)}
* {connected && <span className="pulse" />}
* <button onClick={close}>Stop</button>
* </div>
* );
* }
* ```
*
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
*/
export function usePluginStream<T = unknown>(
channel: string,
options?: { companyId?: string },
): PluginStreamResult<T> {
const impl = getSdkUiRuntimeValue<
(nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult<T>
>("usePluginStream");
return impl(channel, options);
}

View File

@@ -0,0 +1,125 @@
/**
* `@paperclipai/plugin-sdk/ui` — Paperclip plugin UI SDK.
*
* Import this subpath from plugin UI bundles (React components that run in
* the host frontend). Do **not** import this from plugin worker code.
*
* The worker-side SDK is available from `@paperclipai/plugin-sdk` (root).
*
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
*
* @example
* ```tsx
* // Plugin UI bundle entry (dist/ui/index.tsx)
* import {
* usePluginData,
* usePluginAction,
* useHostContext,
* MetricCard,
* StatusBadge,
* Spinner,
* } from "@paperclipai/plugin-sdk/ui";
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
*
* export function DashboardWidget({ context }: PluginWidgetProps) {
* const { data, loading, error } = usePluginData("sync-health", {
* companyId: context.companyId,
* });
* const resync = usePluginAction("resync");
*
* if (loading) return <Spinner />;
* if (error) return <div>Error: {error.message}</div>;
*
* return (
* <div>
* <MetricCard label="Synced Issues" value={data!.syncedCount} />
* <button onClick={() => resync({ companyId: context.companyId })}>
* Resync Now
* </button>
* </div>
* );
* }
* ```
*/
/**
* Bridge hooks for plugin UI components to communicate with the plugin worker.
*
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
* - `useHostContext()` — read the current active company, project, entity, and user IDs
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
*/
export {
usePluginData,
usePluginAction,
useHostContext,
usePluginStream,
} from "./hooks.js";
// Bridge error and host context types
export type {
PluginBridgeError,
PluginBridgeErrorCode,
PluginHostContext,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
PluginRenderCloseHandler,
PluginRenderCloseLifecycle,
PluginRenderEnvironmentContext,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
PluginDataResult,
PluginActionFn,
PluginStreamResult,
} from "./types.js";
// Slot component prop interfaces
export type {
PluginPageProps,
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
PluginProjectSidebarItemProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,
PluginSettingsPageProps,
} from "./types.js";
// Shared UI components
export {
MetricCard,
StatusBadge,
DataTable,
TimeseriesChart,
MarkdownBlock,
KeyValueList,
ActionBar,
LogView,
JsonTree,
Spinner,
ErrorBoundary,
} from "./components.js";
// Shared component prop types (for plugin authors who need to extend them)
export type {
MetricCardProps,
MetricTrend,
StatusBadgeProps,
StatusBadgeVariant,
DataTableProps,
DataTableColumn,
TimeseriesChartProps,
TimeseriesDataPoint,
MarkdownBlockProps,
KeyValueListProps,
KeyValuePair,
ActionBarProps,
ActionBarItem,
LogViewProps,
LogViewEntry,
JsonTreeProps,
SpinnerProps,
ErrorBoundaryProps,
} from "./components.js";

View File

@@ -0,0 +1,51 @@
type PluginBridgeRegistry = {
react?: {
createElement?: (type: unknown, props?: Record<string, unknown> | null) => unknown;
} | null;
sdkUi?: Record<string, unknown> | null;
};
type GlobalBridge = typeof globalThis & {
__paperclipPluginBridge__?: PluginBridgeRegistry;
};
function getBridgeRegistry(): PluginBridgeRegistry | undefined {
return (globalThis as GlobalBridge).__paperclipPluginBridge__;
}
function missingBridgeValueError(name: string): Error {
return new Error(
`Paperclip plugin UI runtime is not initialized for "${name}". ` +
'Ensure the host loaded the plugin bridge before rendering this UI module.',
);
}
export function getSdkUiRuntimeValue<T>(name: string): T {
const value = getBridgeRegistry()?.sdkUi?.[name];
if (value === undefined) {
throw missingBridgeValueError(name);
}
return value as T;
}
export function renderSdkUiComponent<TProps>(
name: string,
props: TProps,
): unknown {
const registry = getBridgeRegistry();
const component = registry?.sdkUi?.[name];
if (component === undefined) {
throw missingBridgeValueError(name);
}
const createElement = registry?.react?.createElement;
if (typeof createElement === "function") {
return createElement(component, props as Record<string, unknown>);
}
if (typeof component === "function") {
return component(props);
}
throw new Error(`Paperclip plugin UI component "${name}" is not callable`);
}

View File

@@ -0,0 +1,358 @@
/**
* Paperclip plugin UI SDK — types for plugin frontend components.
*
* Plugin UI bundles import from `@paperclipai/plugin-sdk/ui`. This subpath
* provides the bridge hooks, component prop interfaces, and error types that
* plugin React components use to communicate with the host.
*
* Plugin UI bundles are loaded as ES modules into designated extension slots.
* All communication with the plugin worker goes through the host bridge — plugin
* components must NOT access host internals or call host APIs directly.
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
*/
import type {
PluginBridgeErrorCode,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
} from "@paperclipai/shared";
import type {
PluginLauncherRenderContextSnapshot,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
} from "../protocol.js";
// Re-export PluginBridgeErrorCode for plugin UI authors
export type {
PluginBridgeErrorCode,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
} from "@paperclipai/shared";
export type {
PluginLauncherRenderContextSnapshot,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
} from "../protocol.js";
// ---------------------------------------------------------------------------
// Bridge error
// ---------------------------------------------------------------------------
/**
* Structured error returned by the bridge when a UI → worker call fails.
*
* Plugin components receive this in `usePluginData()` as the `error` field
* and may encounter it as a thrown value from `usePluginAction()`.
*
* Error codes:
* - `WORKER_UNAVAILABLE` — plugin worker is not running
* - `CAPABILITY_DENIED` — plugin lacks the required capability
* - `WORKER_ERROR` — worker returned an error from its handler
* - `TIMEOUT` — worker did not respond within the configured timeout
* - `UNKNOWN` — unexpected bridge-level failure
*
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
export interface PluginBridgeError {
/** Machine-readable error code. */
code: PluginBridgeErrorCode;
/** Human-readable error message. */
message: string;
/**
* Original error details from the worker, if available.
* Only present when `code === "WORKER_ERROR"`.
*/
details?: unknown;
}
// ---------------------------------------------------------------------------
// Host context available to all plugin components
// ---------------------------------------------------------------------------
/**
* Read-only host context passed to every plugin component via `useHostContext()`.
*
* Plugin components use this to know which company, project, or entity is
* currently active so they can scope their data requests accordingly.
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
*/
export interface PluginHostContext {
/** UUID of the currently active company, if any. */
companyId: string | null;
/** URL prefix for the current company (e.g. `"my-company"`). */
companyPrefix: string | null;
/** UUID of the currently active project, if any. */
projectId: string | null;
/** UUID of the current entity (for detail tab contexts), if any. */
entityId: string | null;
/** Type of the current entity (e.g. `"issue"`, `"agent"`). */
entityType: string | null;
/**
* UUID of the parent entity when rendering nested slots.
* For `commentAnnotation` slots this is the issue ID containing the comment.
*/
parentEntityId?: string | null;
/** UUID of the current authenticated user. */
userId: string | null;
/** Runtime metadata for the host container currently rendering this plugin UI. */
renderEnvironment?: PluginRenderEnvironmentContext | null;
}
/**
* Async-capable callback invoked during a host-managed close lifecycle.
*/
export type PluginRenderCloseHandler = (
event: PluginRenderCloseEvent,
) => void | Promise<void>;
/**
* Close lifecycle hooks available when the plugin UI is rendered inside a
* host-managed launcher environment.
*/
export interface PluginRenderCloseLifecycle {
/** Register a callback before the host closes the current environment. */
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
/** Register a callback after the host closes the current environment. */
onClose?(handler: PluginRenderCloseHandler): () => void;
}
/**
* Runtime information about the host container currently rendering a plugin UI.
*/
export interface PluginRenderEnvironmentContext
extends PluginLauncherRenderContextSnapshot {
/** Optional host callback for requesting new bounds while a modal is open. */
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
/** Optional close lifecycle callbacks for host-managed overlays. */
closeLifecycle?: PluginRenderCloseLifecycle | null;
}
// ---------------------------------------------------------------------------
// Slot component prop interfaces
// ---------------------------------------------------------------------------
/**
* Props passed to a plugin page component.
*
* A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`.
*
* @see PLUGIN_SPEC.md §19.1 — Global Operator Routes
* @see PLUGIN_SPEC.md §19.2 — Company-Context Routes
*/
export interface PluginPageProps {
/** The current host context. */
context: PluginHostContext;
}
/**
* Props passed to a plugin dashboard widget component.
*
* A dashboard widget is rendered as a card or section on the main dashboard.
*
* @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets
*/
export interface PluginWidgetProps {
/** The current host context. */
context: PluginHostContext;
}
/**
* Props passed to a plugin detail tab component.
*
* A detail tab is rendered as an additional tab on a project, issue, agent,
* goal, or run detail page.
*
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
*/
export interface PluginDetailTabProps {
/** The current host context, always including `entityId` and `entityType`. */
context: PluginHostContext & {
entityId: string;
entityType: string;
};
}
/**
* Props passed to a plugin sidebar component.
*
* A sidebar entry adds a link or section to the application sidebar.
*
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
*/
export interface PluginSidebarProps {
/** The current host context. */
context: PluginHostContext;
}
/**
* Props passed to a plugin project sidebar item component.
*
* A project sidebar item is rendered **once per project** under that project's
* row in the sidebar Projects list. The host passes the current project's id
* in `context.entityId` and `context.entityType` is `"project"`.
*
* Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to
* the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`.
*
* @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items
*/
export interface PluginProjectSidebarItemProps {
/** Host context plus entityId (project id) and entityType "project". */
context: PluginHostContext & {
entityId: string;
entityType: "project";
};
}
/**
* Props passed to a plugin comment annotation component.
*
* A comment annotation is rendered below each individual comment in the
* issue detail timeline. The host passes the comment ID as `entityId`
* and `"comment"` as `entityType`, plus the parent issue ID as
* `parentEntityId` so the plugin can scope data fetches to both.
*
* Use this slot to augment comments with parsed file links, sentiment
* badges, inline actions, or any per-comment metadata.
*
* @see PLUGIN_SPEC.md §19.6 — Comment Annotations
*/
export interface PluginCommentAnnotationProps {
/** Host context with comment and parent issue identifiers. */
context: PluginHostContext & {
/** UUID of the comment being annotated. */
entityId: string;
/** Always `"comment"` for comment annotation slots. */
entityType: "comment";
/** UUID of the parent issue containing this comment. */
parentEntityId: string;
};
}
/**
* Props passed to a plugin comment context menu item component.
*
* A comment context menu item is rendered in a "more" dropdown menu on
* each comment in the issue detail timeline. The host passes the comment
* ID as `entityId` and `"comment"` as `entityType`, plus the parent
* issue ID as `parentEntityId`.
*
* Use this slot to add per-comment actions such as "Create sub-issue from
* comment", "Translate", "Flag for review", or any custom plugin action.
*
* @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items
*/
export interface PluginCommentContextMenuItemProps {
/** Host context with comment and parent issue identifiers. */
context: PluginHostContext & {
/** UUID of the comment this menu item acts on. */
entityId: string;
/** Always `"comment"` for comment context menu item slots. */
entityType: "comment";
/** UUID of the parent issue containing this comment. */
parentEntityId: string;
};
}
/**
* Props passed to a plugin settings page component.
*
* Overrides the auto-generated JSON Schema form when the plugin declares
* a `settingsPage` UI slot. The component is responsible for reading and
* writing config through the bridge.
*
* @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI
*/
export interface PluginSettingsPageProps {
/** The current host context. */
context: PluginHostContext;
}
// ---------------------------------------------------------------------------
// usePluginData hook return type
// ---------------------------------------------------------------------------
/**
* Return value of `usePluginData(key, params)`.
*
* Mirrors a standard async data-fetching hook pattern:
* exactly one of `data` or `error` is non-null at any time (unless `loading`).
*
* @template T The type of the data returned by the worker handler
*
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
export interface PluginDataResult<T = unknown> {
/** The data returned by the worker's `getData` handler. `null` while loading or on error. */
data: T | null;
/** `true` while the initial request or a refresh is in flight. */
loading: boolean;
/** Bridge error if the request failed. `null` on success or while loading. */
error: PluginBridgeError | null;
/**
* Manually trigger a data refresh.
* Useful for poll-based updates or post-action refreshes.
*/
refresh(): void;
}
// ---------------------------------------------------------------------------
// usePluginAction hook return type
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// usePluginStream hook return type
// ---------------------------------------------------------------------------
/**
* Return value of `usePluginStream<T>(channel)`.
*
* Provides a growing array of events pushed from the plugin worker via SSE,
* plus connection status metadata.
*
* @template T The type of each event emitted by the worker
*
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
*/
export interface PluginStreamResult<T = unknown> {
/** All events received so far, in arrival order. */
events: T[];
/** The most recently received event, or `null` if none yet. */
lastEvent: T | null;
/** `true` while the SSE connection is being established. */
connecting: boolean;
/** `true` once the SSE connection is open and receiving events. */
connected: boolean;
/** Error if the SSE connection failed or was interrupted. `null` otherwise. */
error: Error | null;
/** Close the SSE connection and stop receiving events. */
close(): void;
}
// ---------------------------------------------------------------------------
// usePluginAction hook return type
// ---------------------------------------------------------------------------
/**
* Return value of `usePluginAction(key)`.
*
* Returns an async function that, when called, sends an action request
* to the worker's `performAction` handler and returns the result.
*
* On failure, the async function throws a `PluginBridgeError`.
*
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*
* @example
* ```tsx
* const resync = usePluginAction("resync");
* <button onClick={() => resync({ companyId }).catch(err => console.error(err))}>
* Resync Now
* </button>
* ```
*/
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;

File diff suppressed because it is too large Load Diff