Clarify plugin authoring and external dev workflow
This commit is contained in:
154
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
154
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `tests/plugin.spec.ts`
|
||||
- `esbuild.config.mjs`
|
||||
- `rollup.config.mjs`
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Recommended local workflow
|
||||
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
|
||||
- config
|
||||
- events
|
||||
- jobs
|
||||
- launchers
|
||||
- http
|
||||
- secrets
|
||||
- activity
|
||||
- state
|
||||
- entities
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues and comments
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
- tools
|
||||
- metrics
|
||||
- logger
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
- `usePluginAction`
|
||||
- `usePluginStream`
|
||||
- `usePluginToast`
|
||||
- `useHostContext`
|
||||
- typed slot props from `@paperclipai/plugin-sdk/ui`
|
||||
|
||||
Mount surfaces currently wired in the host include:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
```text
|
||||
/:companyPrefix/<routePath>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `routePath` must be a single lowercase slug
|
||||
- it cannot collide with reserved host routes
|
||||
- it cannot duplicate another installed plugin page route
|
||||
|
||||
## Publishing guidance
|
||||
|
||||
- Use npm packages as the deployment artifact.
|
||||
- Treat repo-local example installs as a development workflow only.
|
||||
- Prefer keeping plugin UI self-contained inside the package.
|
||||
- Do not rely on host design-system components or undocumented app internals.
|
||||
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
|
||||
|
||||
## Verification before handoff
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
pnpm --filter <your-plugin-package> typecheck
|
||||
pnpm --filter <your-plugin-package> test
|
||||
pnpm --filter <your-plugin-package> build
|
||||
```
|
||||
|
||||
If you changed host integration too, also run:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
@@ -20,11 +20,14 @@ Today, the practical deployment model is:
|
||||
|
||||
Current limitations to keep in mind:
|
||||
|
||||
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
|
||||
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
|
||||
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
|
||||
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
|
||||
@@ -22,11 +22,25 @@ Supported categories: `connector`, `workspace`, `automation`, `ui`
|
||||
|
||||
Generates:
|
||||
- typed manifest + worker entrypoint
|
||||
- example UI widget using `@paperclipai/plugin-sdk/ui`
|
||||
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
|
||||
- test file using `@paperclipai/plugin-sdk/testing`
|
||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||
|
||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
||||
|
||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||
|
||||
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
|
||||
|
||||
```bash
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
|
||||
--output /absolute/path/to/plugins \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That gives you an outside-repo local development path before the SDK is published to npm.
|
||||
|
||||
## Workflow after scaffolding
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||
@@ -14,6 +16,7 @@ export interface ScaffoldPluginOptions {
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: "connector" | "workspace" | "automation" | "ui";
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
/** Validate npm-style plugin package names (scoped or unscoped). */
|
||||
@@ -55,6 +58,58 @@ function quote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function formatFileDependency(absPath: string): string {
|
||||
return `file:${toPosixPath(path.resolve(absPath))}`;
|
||||
}
|
||||
|
||||
function getLocalSdkPackagePath(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
|
||||
}
|
||||
|
||||
function getRepoRootFromSdkPath(sdkPath: string): string {
|
||||
return path.resolve(sdkPath, "..", "..", "..");
|
||||
}
|
||||
|
||||
function getLocalSharedPackagePath(sdkPath: string): string {
|
||||
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
|
||||
}
|
||||
|
||||
function isInsideDir(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function packLocalPackage(packagePath: string, outputDir: string): string {
|
||||
const packageJsonPath = path.join(packagePath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(`Package package.json not found at ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
const packageName = packageJson.name ?? path.basename(packagePath);
|
||||
const packageVersion = packageJson.version ?? "0.0.0";
|
||||
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
|
||||
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
|
||||
|
||||
fs.mkdirSync(sdkBundleDir, { recursive: true });
|
||||
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
|
||||
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
|
||||
|
||||
const tarballPath = path.join(sdkBundleDir, tarballFileName);
|
||||
if (!fs.existsSync(tarballPath)) {
|
||||
throw new Error(`Packed tarball was not created at ${tarballPath}`);
|
||||
}
|
||||
|
||||
return tarballPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete Paperclip plugin starter project.
|
||||
*
|
||||
@@ -85,9 +140,18 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
const author = options.author ?? "Plugin Author";
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||
const manifestId = packageToManifestId(options.pluginName);
|
||||
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
||||
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
||||
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
|
||||
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
|
||||
const sdkDependency = useWorkspaceSdk
|
||||
? "workspace:*"
|
||||
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
|
||||
|
||||
const packageJson = {
|
||||
name: options.pluginName,
|
||||
version: "0.1.0",
|
||||
@@ -99,7 +163,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
"build:rollup": "rollup -c",
|
||||
dev: "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
test: "vitest run",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
typecheck: "tsc --noEmit"
|
||||
},
|
||||
paperclipPlugin: {
|
||||
@@ -110,10 +174,22 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
keywords: ["paperclip", "plugin", category],
|
||||
author,
|
||||
license: "MIT",
|
||||
dependencies: {
|
||||
"@paperclipai/plugin-sdk": "^1.0.0"
|
||||
},
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
pnpm: {
|
||||
overrides: {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
devDependencies: {
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
}
|
||||
: {}),
|
||||
"@paperclipai/plugin-sdk": sdkDependency,
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
@@ -144,7 +220,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
declarationMap: true,
|
||||
sourceMap: true,
|
||||
outDir: "dist",
|
||||
rootDir: "src"
|
||||
rootDir: "."
|
||||
},
|
||||
include: ["src", "tests"],
|
||||
exclude: ["dist", "node_modules"]
|
||||
@@ -207,6 +283,19 @@ export default [
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "vitest.config.ts"),
|
||||
`import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
@@ -278,7 +367,7 @@ runWorker(plugin, import.meta.url);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { MetricCard, StatusBadge, usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
@@ -290,11 +379,13 @@ export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <StatusBadge label={error.message} status="error" />;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<MetricCard label="Health" value={data?.status ?? "unknown"} />
|
||||
<strong>${displayName}</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
@@ -342,10 +433,16 @@ pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
\`\`\`
|
||||
|
||||
${sdkDependency.startsWith("file:")
|
||||
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
||||
: ""}
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
\`\`\`bash
|
||||
pnpm paperclipai plugin install ./
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
|
||||
\`\`\`
|
||||
|
||||
## Build Options
|
||||
@@ -355,7 +452,7 @@ pnpm paperclipai plugin install ./
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n");
|
||||
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
@@ -371,7 +468,7 @@ function runCli() {
|
||||
const pluginName = process.argv[2];
|
||||
if (!pluginName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>]");
|
||||
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -387,6 +484,7 @@ function runCli() {
|
||||
description: parseArg("--description"),
|
||||
author: parseArg("--author"),
|
||||
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
||||
sdkPath: parseArg("--sdk-path"),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plugin Authoring Smoke Example
|
||||
|
||||
A Paperclip plugin
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-authoring-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "A Paperclip plugin",
|
||||
"scripts": {
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"connector"
|
||||
],
|
||||
"author": "Plugin Author",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-authoring-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Authoring Smoke Example",
|
||||
description: "A Paperclip plugin",
|
||||
author: "Plugin Author",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Plugin Authoring Smoke Example Health",
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>Plugin Authoring Smoke Example</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -39,8 +39,6 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"assets.write",
|
||||
"assets.read",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"plugin.state.read",
|
||||
|
||||
@@ -71,7 +71,6 @@ type OverviewData = {
|
||||
};
|
||||
lastJob: unknown;
|
||||
lastWebhook: unknown;
|
||||
lastAsset: unknown;
|
||||
lastProcessResult: unknown;
|
||||
streamChannels: Record<string, string>;
|
||||
safeCommands: Array<{ key: string; label: string; description: string }>;
|
||||
@@ -766,7 +765,6 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
|
||||
value={{
|
||||
lastJob: overview.data?.lastJob ?? null,
|
||||
lastWebhook: overview.data?.lastWebhook ?? null,
|
||||
lastAsset: overview.data?.lastAsset ?? null,
|
||||
lastProcessResult: overview.data?.lastProcessResult ?? null,
|
||||
}}
|
||||
/>
|
||||
@@ -1340,7 +1338,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
const [secretRef, setSecretRef] = useState("");
|
||||
const [metricName, setMetricName] = useState("manual");
|
||||
const [metricValue, setMetricValue] = useState("1");
|
||||
const [assetContent, setAssetContent] = useState("Kitchen Sink asset demo");
|
||||
const [workspaceId, setWorkspaceId] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
|
||||
const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file.");
|
||||
@@ -1388,7 +1385,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
const writeMetric = usePluginAction("write-metric");
|
||||
const httpFetch = usePluginAction("http-fetch");
|
||||
const resolveSecret = usePluginAction("resolve-secret");
|
||||
const createAsset = usePluginAction("create-asset");
|
||||
const runProcess = usePluginAction("run-process");
|
||||
const readWorkspaceFile = usePluginAction("read-workspace-file");
|
||||
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
|
||||
@@ -1808,7 +1804,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="HTTP + Secrets + Assets + Activity + Metrics">
|
||||
<Section title="HTTP + Secrets + Activity + Metrics">
|
||||
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
||||
<form
|
||||
style={layoutStack}
|
||||
@@ -1836,22 +1832,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
<input style={inputStyle} value={secretRef} onChange={(event) => setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
|
||||
<button type="submit" style={buttonStyle}>Resolve secret ref</button>
|
||||
</form>
|
||||
<form
|
||||
style={layoutStack}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createAsset({ filename: "kitchen-sink-demo.txt", content: assetContent })
|
||||
.then((next) => {
|
||||
setResult(next);
|
||||
overview.refresh();
|
||||
})
|
||||
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
|
||||
}}
|
||||
>
|
||||
<strong>Assets</strong>
|
||||
<textarea style={{ ...inputStyle, minHeight: "88px" }} value={assetContent} onChange={(event) => setAssetContent(event.target.value)} />
|
||||
<button type="submit" style={buttonStyle}>Upload text asset</button>
|
||||
</form>
|
||||
<form
|
||||
style={layoutStack}
|
||||
onSubmit={(event) => {
|
||||
|
||||
@@ -262,7 +262,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||
const lastJob = await readInstanceState(ctx, "last-job-run");
|
||||
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
||||
const lastAsset = await readInstanceState(ctx, "last-asset");
|
||||
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
|
||||
return {
|
||||
pluginId: PLUGIN_ID,
|
||||
@@ -281,7 +280,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
},
|
||||
lastJob,
|
||||
lastWebhook,
|
||||
lastAsset,
|
||||
lastProcessResult,
|
||||
streamChannels: STREAM_CHANNELS,
|
||||
safeCommands: SAFE_COMMANDS,
|
||||
@@ -619,24 +617,6 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("create-asset", async (params) => {
|
||||
const filename = typeof params.filename === "string" && params.filename.length > 0
|
||||
? params.filename
|
||||
: `kitchen-sink-${Date.now()}.txt`;
|
||||
const content = typeof params.content === "string" ? params.content : "Kitchen Sink example asset";
|
||||
const uploaded = await ctx.assets.upload(filename, "text/plain", Buffer.from(content, "utf8"));
|
||||
const url = await ctx.assets.getUrl(uploaded.assetId);
|
||||
const result = { ...uploaded, url };
|
||||
await writeInstanceState(ctx, "last-asset", result);
|
||||
pushRecord({
|
||||
level: "info",
|
||||
source: "assets",
|
||||
message: `Uploaded asset ${filename}`,
|
||||
data: { assetId: uploaded.assetId },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ctx.actions.register("run-process", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Official TypeScript SDK for Paperclip plugin authors.
|
||||
|
||||
- **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle
|
||||
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks, components, slot props
|
||||
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
|
||||
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
|
||||
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
|
||||
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
|
||||
@@ -15,10 +15,9 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
|
||||
| Import | Purpose |
|
||||
|--------|--------|
|
||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, shared components |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
|
||||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||||
| `@paperclipai/plugin-sdk/ui/components` | `MetricCard`, `StatusBadge`, `Spinner`, `ErrorBoundary`, etc. |
|
||||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||||
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
||||
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
|
||||
@@ -42,10 +41,14 @@ pnpm add @paperclipai/plugin-sdk
|
||||
|
||||
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
|
||||
|
||||
- Plugin workers and plugin UI should both be treated as trusted code today.
|
||||
- Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
|
||||
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
|
||||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||||
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
|
||||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||||
|
||||
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
||||
|
||||
@@ -97,7 +100,7 @@ runWorker(plugin, import.meta.url);
|
||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||
|
||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `assets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. All host APIs are capability-gated; declare capabilities in the manifest.
|
||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
|
||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||
|
||||
@@ -126,7 +129,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
|
||||
|
||||
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
|
||||
|
||||
**Company-scoped delivery:** Events with a `companyId` are only delivered to plugins that are enabled for that company. If a company has disabled a plugin via settings, that plugin's handlers will not receive events belonging to that company. Events without a `companyId` are delivered to all subscribers.
|
||||
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
|
||||
|
||||
## Scheduled (recurring) jobs
|
||||
|
||||
@@ -302,8 +305,6 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
| | `issues.create` |
|
||||
| | `issues.update` |
|
||||
| | `issue.comments.create` |
|
||||
| | `assets.write` |
|
||||
| | `assets.read` |
|
||||
| | `activity.log.write` |
|
||||
| | `metrics.write` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
@@ -333,14 +334,15 @@ Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
||||
## UI quick start
|
||||
|
||||
```tsx
|
||||
import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function DashboardWidget() {
|
||||
const { data } = usePluginData<{ status: string }>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
return (
|
||||
<div>
|
||||
<MetricCard label="Health" value={data?.status ?? "unknown"} />
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<strong>Health</strong>
|
||||
<div>{data?.status ?? "unknown"}</div>
|
||||
<button onClick={() => void ping()}>Ping</button>
|
||||
</div>
|
||||
);
|
||||
@@ -354,7 +356,7 @@ export function DashboardWidget() {
|
||||
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
|
||||
|
||||
```tsx
|
||||
import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface SyncStatus {
|
||||
lastSyncAt: string;
|
||||
@@ -367,12 +369,12 @@ export function SyncStatusWidget({ context }: PluginWidgetProps) {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <StatusBadge label={error.message} status="error" />;
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatusBadge label={data!.healthy ? "Healthy" : "Unhealthy"} status={data!.healthy ? "ok" : "error"} />
|
||||
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
|
||||
<p>Synced {data!.syncedCount} items</p>
|
||||
<p>Last sync: {data!.lastSyncAt}</p>
|
||||
<button onClick={refresh}>Refresh</button>
|
||||
@@ -465,100 +467,9 @@ export function ChatMessages({ context }: PluginWidgetProps) {
|
||||
|
||||
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
|
||||
|
||||
### Shared components reference
|
||||
### UI authoring note
|
||||
|
||||
All components are provided by the host at runtime and match the host design tokens. Import from `@paperclipai/plugin-sdk/ui` or `@paperclipai/plugin-sdk/ui/components`.
|
||||
|
||||
#### `MetricCard`
|
||||
|
||||
Displays a single metric value with optional trend and sparkline.
|
||||
|
||||
```tsx
|
||||
<MetricCard label="Issues Synced" value={142} unit="issues" trend={{ direction: "up", percentage: 12 }} />
|
||||
<MetricCard label="API Latency" value="45ms" sparkline={[52, 48, 45, 47, 45]} />
|
||||
```
|
||||
|
||||
#### `StatusBadge`
|
||||
|
||||
Inline status indicator with semantic color.
|
||||
|
||||
```tsx
|
||||
<StatusBadge label="Connected" status="ok" />
|
||||
<StatusBadge label="Rate Limited" status="warning" />
|
||||
<StatusBadge label="Auth Failed" status="error" />
|
||||
```
|
||||
|
||||
#### `DataTable`
|
||||
|
||||
Sortable, paginated table.
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", width: "100px" },
|
||||
{ key: "updatedAt", header: "Updated", render: (v) => new Date(v as string).toLocaleDateString() },
|
||||
]}
|
||||
rows={issues}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
onPageChange={setPage}
|
||||
onSort={(key, dir) => setSortBy({ key, dir })}
|
||||
/>
|
||||
```
|
||||
|
||||
#### `TimeseriesChart`
|
||||
|
||||
Line or bar chart for time-series data.
|
||||
|
||||
```tsx
|
||||
<TimeseriesChart
|
||||
title="Sync Frequency"
|
||||
data={[
|
||||
{ timestamp: "2026-03-01T00:00:00Z", value: 24 },
|
||||
{ timestamp: "2026-03-02T00:00:00Z", value: 31 },
|
||||
{ timestamp: "2026-03-03T00:00:00Z", value: 28 },
|
||||
]}
|
||||
type="bar"
|
||||
yLabel="Syncs"
|
||||
height={250}
|
||||
/>
|
||||
```
|
||||
|
||||
#### `ActionBar`
|
||||
|
||||
Row of action buttons wired to the plugin bridge.
|
||||
|
||||
```tsx
|
||||
<ActionBar
|
||||
actions={[
|
||||
{ label: "Sync Now", actionKey: "sync", variant: "primary" },
|
||||
{ label: "Clear Cache", actionKey: "clear-cache", confirm: true, confirmMessage: "Delete all cached data?" },
|
||||
]}
|
||||
onSuccess={(key) => data.refresh()}
|
||||
onError={(key, err) => console.error(key, err)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### `LogView`, `JsonTree`, `KeyValueList`, `MarkdownBlock`
|
||||
|
||||
```tsx
|
||||
<LogView entries={logEntries} maxHeight="300px" autoScroll />
|
||||
<JsonTree data={debugPayload} defaultExpandDepth={3} />
|
||||
<KeyValueList pairs={[{ label: "Plugin ID", value: pluginId }, { label: "Version", value: "1.2.0" }]} />
|
||||
<MarkdownBlock content="**Bold** text and `code` blocks are supported." />
|
||||
```
|
||||
|
||||
#### `Spinner`, `ErrorBoundary`
|
||||
|
||||
```tsx
|
||||
<Spinner size="lg" label="Loading plugin data..." />
|
||||
|
||||
<ErrorBoundary fallback={<p>Something went wrong.</p>} onError={(err) => console.error(err)}>
|
||||
<MyPluginContent />
|
||||
</ErrorBoundary>
|
||||
```
|
||||
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
|
||||
|
||||
### Slot component props
|
||||
|
||||
@@ -579,7 +490,7 @@ Example detail tab with entity context:
|
||||
|
||||
```tsx
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, KeyValueList, Spinner } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
||||
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
|
||||
@@ -587,13 +498,18 @@ export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (!data) return <p>No metrics available.</p>;
|
||||
|
||||
return (
|
||||
<KeyValueList
|
||||
pairs={Object.entries(data).map(([label, value]) => ({ label, value }))}
|
||||
/>
|
||||
<dl>
|
||||
{Object.entries(data).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -702,8 +618,6 @@ For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal i
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Spinner,
|
||||
useHostContext,
|
||||
usePluginAction,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
@@ -730,7 +644,7 @@ export function SyncToolbarButton() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Sync
|
||||
</button>
|
||||
@@ -757,13 +671,13 @@ export function SyncToolbarButton() {
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
||||
{submitting ? <Spinner size="sm" /> : "Run sync"}
|
||||
{submitting ? "Running…" : "Run sync"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,10 +28,6 @@
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./ui/components": {
|
||||
"types": "./dist/ui/components.d.ts",
|
||||
"import": "./dist/ui/components.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
@@ -75,10 +71,6 @@
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./ui/components": {
|
||||
"types": "./dist/ui/components.d.ts",
|
||||
"import": "./dist/ui/components.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
|
||||
@@ -62,7 +62,6 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
||||
const uiExternal = [
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
"@paperclipai/plugin-sdk/ui/hooks",
|
||||
"@paperclipai/plugin-sdk/ui/components",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
@@ -84,7 +83,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"],
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const esbuildManifest: EsbuildLikeOptions = {
|
||||
@@ -119,7 +118,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
||||
sourcemap,
|
||||
entryFileNames: "worker.js",
|
||||
},
|
||||
external: ["@paperclipai/plugin-sdk", "react", "react-dom"],
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const rollupManifest: RollupLikeConfig = {
|
||||
|
||||
@@ -118,12 +118,6 @@ export interface HostServices {
|
||||
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: {
|
||||
@@ -274,10 +268,6 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
// Secrets
|
||||
"secrets.resolve": "secrets.read-ref",
|
||||
|
||||
// Assets
|
||||
"assets.upload": "assets.write",
|
||||
"assets.getUrl": "assets.read",
|
||||
|
||||
// Activity
|
||||
"activity.log": "activity.log.write",
|
||||
|
||||
@@ -428,14 +418,6 @@ export function createHostClientHandlers(
|
||||
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);
|
||||
|
||||
@@ -164,7 +164,6 @@ export type {
|
||||
PluginLaunchersClient,
|
||||
PluginHttpClient,
|
||||
PluginSecretsClient,
|
||||
PluginAssetsClient,
|
||||
PluginActivityClient,
|
||||
PluginActivityLogEntry,
|
||||
PluginStateClient,
|
||||
|
||||
@@ -495,16 +495,6 @@ export interface WorkerToHostMethods {
|
||||
result: string,
|
||||
];
|
||||
|
||||
// Assets
|
||||
"assets.upload": [
|
||||
params: { filename: string; contentType: string; data: string },
|
||||
result: { assetId: string; url: string },
|
||||
];
|
||||
"assets.getUrl": [
|
||||
params: { assetId: string },
|
||||
result: string,
|
||||
];
|
||||
|
||||
// Activity
|
||||
"activity.log": [
|
||||
params: {
|
||||
|
||||
@@ -136,8 +136,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
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>();
|
||||
@@ -207,19 +205,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
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");
|
||||
|
||||
@@ -452,34 +452,6 @@ export interface PluginSecretsClient {
|
||||
resolve(secretRef: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.assets` — read and write assets (files, images, etc.).
|
||||
*
|
||||
* `assets.read` capability required for `getUrl()`.
|
||||
* `assets.write` capability required for `upload()`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write
|
||||
*/
|
||||
export interface PluginAssetsClient {
|
||||
/**
|
||||
* Upload an asset (e.g. a screenshot or generated file).
|
||||
*
|
||||
* @param filename - Name for the asset file
|
||||
* @param contentType - MIME type
|
||||
* @param data - Raw asset data as a Buffer or Uint8Array
|
||||
* @returns The asset ID and public URL
|
||||
*/
|
||||
upload(filename: string, contentType: string, data: Buffer | Uint8Array): Promise<{ assetId: string; url: string }>;
|
||||
|
||||
/**
|
||||
* Get the public URL for an existing asset by ID.
|
||||
*
|
||||
* @param assetId - Asset identifier
|
||||
* @returns The public URL
|
||||
*/
|
||||
getUrl(assetId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for writing a plugin activity log entry.
|
||||
*
|
||||
@@ -1069,9 +1041,6 @@ export interface PluginContext {
|
||||
/** Resolve secret references. Requires `secrets.read-ref`. */
|
||||
secrets: PluginSecretsClient;
|
||||
|
||||
/** Read and write assets. Requires `assets.read` / `assets.write`. */
|
||||
assets: PluginAssetsClient;
|
||||
|
||||
/** Write activity log entries. Requires `activity.log.write`. */
|
||||
activity: PluginActivityClient;
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ import { getSdkUiRuntimeValue } from "./runtime.js";
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
*
|
||||
* if (loading) return <Spinner />;
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
* return <MetricCard label="Synced Issues" value={data!.syncedCount} />;
|
||||
* return <div>Synced Issues: {data!.syncedCount}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
|
||||
@@ -12,14 +12,7 @@
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
||||
* import {
|
||||
* usePluginData,
|
||||
* usePluginAction,
|
||||
* useHostContext,
|
||||
* MetricCard,
|
||||
* StatusBadge,
|
||||
* Spinner,
|
||||
* } from "@paperclipai/plugin-sdk/ui";
|
||||
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
*
|
||||
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
@@ -28,12 +21,13 @@
|
||||
* });
|
||||
* const resync = usePluginAction("resync");
|
||||
*
|
||||
* if (loading) return <Spinner />;
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <MetricCard label="Synced Issues" value={data!.syncedCount} />
|
||||
* <div style={{ display: "grid", gap: 8 }}>
|
||||
* <strong>Synced Issues</strong>
|
||||
* <div>{data!.syncedCount}</div>
|
||||
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
@@ -91,40 +85,3 @@ export type {
|
||||
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";
|
||||
|
||||
@@ -456,26 +456,6 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
assets: {
|
||||
async upload(
|
||||
filename: string,
|
||||
contentType: string,
|
||||
data: Buffer | Uint8Array,
|
||||
): Promise<{ assetId: string; url: string }> {
|
||||
// Base64-encode binary data for JSON serialization
|
||||
const base64 = Buffer.from(data).toString("base64");
|
||||
return callHost("assets.upload", {
|
||||
filename,
|
||||
contentType,
|
||||
data: base64,
|
||||
});
|
||||
},
|
||||
|
||||
async getUrl(assetId: string): Promise<string> {
|
||||
return callHost("assets.getUrl", { assetId });
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
async log(entry): Promise<void> {
|
||||
await callHost("activity.log", {
|
||||
|
||||
@@ -331,8 +331,6 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"assets.write",
|
||||
"assets.read",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
// Plugin State
|
||||
|
||||
@@ -61,15 +61,11 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"activity.get": ["activity.read"],
|
||||
"costs.list": ["costs.read"],
|
||||
"costs.get": ["costs.read"],
|
||||
"assets.list": ["assets.read"],
|
||||
"assets.get": ["assets.read"],
|
||||
|
||||
// Data write operations
|
||||
"issues.create": ["issues.create"],
|
||||
"issues.update": ["issues.update"],
|
||||
"issue.comments.create": ["issue.comments.create"],
|
||||
"assets.upload": ["assets.write"],
|
||||
"assets.delete": ["assets.write"],
|
||||
"activity.log": ["activity.log.write"],
|
||||
"metrics.write": ["metrics.write"],
|
||||
|
||||
|
||||
@@ -582,17 +582,6 @@ export function buildHostServices(
|
||||
},
|
||||
},
|
||||
|
||||
assets: {
|
||||
async upload(params) {
|
||||
void params;
|
||||
throw new Error("Plugin asset uploads are not supported in this build.");
|
||||
},
|
||||
async getUrl(params) {
|
||||
void params;
|
||||
throw new Error("Plugin asset URLs are not supported in this build.");
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
async log(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
|
||||
101
skills/paperclip-create-plugin/SKILL.md
Normal file
101
skills/paperclip-create-plugin/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: paperclip-create-plugin
|
||||
description: >
|
||||
Create new Paperclip plugins with the current alpha SDK/runtime. Use when
|
||||
scaffolding a plugin package, adding a new example plugin, or updating plugin
|
||||
authoring docs. Covers the supported worker/UI surface, route conventions,
|
||||
scaffold flow, and verification steps.
|
||||
---
|
||||
|
||||
# Create a Paperclip Plugin
|
||||
|
||||
Use this skill when the task is to create, scaffold, or document a Paperclip plugin.
|
||||
|
||||
## 1. Ground rules
|
||||
|
||||
Read these first when needed:
|
||||
|
||||
1. `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`
|
||||
2. `packages/plugins/sdk/README.md`
|
||||
3. `doc/plugins/PLUGIN_SPEC.md` only for future-looking context
|
||||
|
||||
Current runtime assumptions:
|
||||
|
||||
- plugin workers are trusted code
|
||||
- plugin UI is trusted same-origin host code
|
||||
- worker APIs are capability-gated
|
||||
- plugin UI is not sandboxed by manifest capabilities
|
||||
- no host-provided shared plugin UI component kit yet
|
||||
- `ctx.assets` is not supported in the current runtime
|
||||
|
||||
## 2. Preferred workflow
|
||||
|
||||
Use the scaffold package instead of hand-writing the boilerplate:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js <npm-package-name> --output <target-dir>
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo, pass `--sdk-path` and let the scaffold snapshot the local SDK/shared packages into `.paperclip-sdk/`:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
Recommended target inside this repo:
|
||||
|
||||
- `packages/plugins/examples/` for example plugins
|
||||
- another `packages/plugins/<name>/` folder if it is becoming a real package
|
||||
|
||||
## 3. After scaffolding
|
||||
|
||||
Check and adjust:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `tests/plugin.spec.ts`
|
||||
- `package.json`
|
||||
|
||||
Make sure the plugin:
|
||||
|
||||
- declares only supported capabilities
|
||||
- does not use `ctx.assets`
|
||||
- does not import host UI component stubs
|
||||
- keeps UI self-contained
|
||||
- uses `routePath` only on `page` slots
|
||||
- is installed into Paperclip from an absolute local path during development
|
||||
|
||||
## 4. If the plugin should appear in the app
|
||||
|
||||
For bundled example/discoverable behavior, update the relevant host wiring:
|
||||
|
||||
- bundled example list in `server/src/routes/plugins.ts`
|
||||
- any docs that list in-repo examples
|
||||
|
||||
Only do this if the user wants the plugin surfaced as a bundled example.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
Always run:
|
||||
|
||||
```bash
|
||||
pnpm --filter <plugin-package> typecheck
|
||||
pnpm --filter <plugin-package> test
|
||||
pnpm --filter <plugin-package> build
|
||||
```
|
||||
|
||||
If you changed SDK/host/plugin runtime code too, also run broader repo checks as appropriate.
|
||||
|
||||
## 6. Documentation expectations
|
||||
|
||||
When authoring or updating plugin docs:
|
||||
|
||||
- distinguish current implementation from future spec ideas
|
||||
- be explicit about the trusted-code model
|
||||
- do not promise host UI components or asset APIs
|
||||
- prefer npm-package deployment guidance over repo-local workflows for production
|
||||
@@ -8,6 +8,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PluginSlotMount } from "@/plugins/slots";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { NotFoundPage } from "./NotFound";
|
||||
|
||||
/**
|
||||
* Company-context plugin page. Renders a plugin's `page` slot at
|
||||
@@ -25,12 +26,18 @@ export function PluginPage() {
|
||||
}>();
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const routeCompany = useMemo(() => {
|
||||
if (!routeCompanyPrefix) return null;
|
||||
const requested = routeCompanyPrefix.toUpperCase();
|
||||
return companies.find((c) => c.issuePrefix.toUpperCase() === requested) ?? null;
|
||||
}, [companies, routeCompanyPrefix]);
|
||||
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
|
||||
|
||||
const resolvedCompanyId = useMemo(() => {
|
||||
if (!routeCompanyPrefix) return selectedCompanyId ?? null;
|
||||
const requested = routeCompanyPrefix.toUpperCase();
|
||||
return companies.find((c) => c.issuePrefix.toUpperCase() === requested)?.id ?? selectedCompanyId ?? null;
|
||||
}, [companies, routeCompanyPrefix, selectedCompanyId]);
|
||||
if (routeCompany) return routeCompany.id;
|
||||
if (routeCompanyPrefix) return null;
|
||||
return selectedCompanyId ?? null;
|
||||
}, [routeCompany, routeCompanyPrefix, selectedCompanyId]);
|
||||
|
||||
const companyPrefix = useMemo(
|
||||
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
|
||||
@@ -92,6 +99,9 @@ export function PluginPage() {
|
||||
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
||||
|
||||
if (!resolvedCompanyId) {
|
||||
if (hasInvalidCompanyPrefix) {
|
||||
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
|
||||
@@ -117,6 +127,9 @@ export function PluginPage() {
|
||||
}
|
||||
|
||||
if (!pageSlot) {
|
||||
if (pluginRoutePath) {
|
||||
return <NotFoundPage scope="board" />;
|
||||
}
|
||||
// No page slot: redirect to plugin settings where plugin info is always shown
|
||||
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
|
||||
return <Navigate to={settingsPath} replace />;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
@@ -60,61 +59,11 @@ export function initPluginBridge(
|
||||
react,
|
||||
reactDom,
|
||||
sdkUi: {
|
||||
// Bridge hooks
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
|
||||
// Placeholder shared UI components — plugins that use these will get
|
||||
// functional stubs. Full implementations matching the host's design
|
||||
// system can be added later.
|
||||
MetricCard: createStubComponent("MetricCard"),
|
||||
StatusBadge: createStubComponent("StatusBadge"),
|
||||
DataTable: createStubComponent("DataTable"),
|
||||
TimeseriesChart: createStubComponent("TimeseriesChart"),
|
||||
MarkdownBlock: createStubComponent("MarkdownBlock"),
|
||||
KeyValueList: createStubComponent("KeyValueList"),
|
||||
ActionBar: createStubComponent("ActionBar"),
|
||||
LogView: createStubComponent("LogView"),
|
||||
JsonTree: createStubComponent("JsonTree"),
|
||||
Spinner: createStubComponent("Spinner"),
|
||||
ErrorBoundary: createPassthroughComponent("ErrorBoundary"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub component helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStubComponent(name: string): unknown {
|
||||
const fn = (props: Record<string, unknown>) => {
|
||||
// Import React from the registry to avoid import issues
|
||||
const React = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
|
||||
if (!React) return null;
|
||||
return React.createElement("div", {
|
||||
"data-plugin-component": name,
|
||||
style: {
|
||||
padding: "8px",
|
||||
border: "1px dashed #666",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
color: "#888",
|
||||
},
|
||||
}, `[${name}]`);
|
||||
};
|
||||
Object.defineProperty(fn, "name", { value: name });
|
||||
return fn;
|
||||
}
|
||||
|
||||
function createPassthroughComponent(name: string): unknown {
|
||||
const fn = (props: { children?: ReactNode }) => {
|
||||
const ReactLib = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
|
||||
if (!ReactLib) return null;
|
||||
return ReactLib.createElement(ReactLib.Fragment, null, props.children);
|
||||
};
|
||||
Object.defineProperty(fn, "name", { value: name });
|
||||
return fn;
|
||||
}
|
||||
|
||||
@@ -257,14 +257,8 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
|
||||
case "sdk-ui":
|
||||
source = `
|
||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||
Spinner, ErrorBoundary } = SDK;
|
||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||
Spinner, ErrorBoundary };
|
||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
|
||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
|
||||
`;
|
||||
break;
|
||||
}
|
||||
@@ -294,8 +288,6 @@ function rewriteBareSpecifiers(source: string): string {
|
||||
"'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`,
|
||||
'"@paperclipai/plugin-sdk/ui/hooks"': `"${getShimBlobUrl("sdk-ui")}"`,
|
||||
"'@paperclipai/plugin-sdk/ui/hooks'": `'${getShimBlobUrl("sdk-ui")}'`,
|
||||
'"@paperclipai/plugin-sdk/ui/components"': `"${getShimBlobUrl("sdk-ui")}"`,
|
||||
"'@paperclipai/plugin-sdk/ui/components'": `'${getShimBlobUrl("sdk-ui")}'`,
|
||||
'"react/jsx-runtime"': `"${getShimBlobUrl("react/jsx-runtime")}"`,
|
||||
"'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`,
|
||||
'"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`,
|
||||
|
||||
Reference in New Issue
Block a user