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:
|
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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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:
|
Generates:
|
||||||
- typed manifest + worker entrypoint
|
- 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`
|
- test file using `@paperclipai/plugin-sdk/testing`
|
||||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
- 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
|
## Workflow after scaffolding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||||
@@ -14,6 +16,7 @@ export interface ScaffoldPluginOptions {
|
|||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
category?: "connector" | "workspace" | "automation" | "ui";
|
category?: "connector" | "workspace" | "automation" | "ui";
|
||||||
|
sdkPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate npm-style plugin package names (scoped or unscoped). */
|
/** Validate npm-style plugin package names (scoped or unscoped). */
|
||||||
@@ -55,6 +58,58 @@ function quote(value: string): string {
|
|||||||
return JSON.stringify(value);
|
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.
|
* Generate a complete Paperclip plugin starter project.
|
||||||
*
|
*
|
||||||
@@ -85,9 +140,18 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|||||||
const author = options.author ?? "Plugin Author";
|
const author = options.author ?? "Plugin Author";
|
||||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||||
const manifestId = packageToManifestId(options.pluginName);
|
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 });
|
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 = {
|
const packageJson = {
|
||||||
name: options.pluginName,
|
name: options.pluginName,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -99,7 +163,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|||||||
"build:rollup": "rollup -c",
|
"build:rollup": "rollup -c",
|
||||||
dev: "node ./esbuild.config.mjs --watch",
|
dev: "node ./esbuild.config.mjs --watch",
|
||||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
"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"
|
typecheck: "tsc --noEmit"
|
||||||
},
|
},
|
||||||
paperclipPlugin: {
|
paperclipPlugin: {
|
||||||
@@ -110,10 +174,22 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|||||||
keywords: ["paperclip", "plugin", category],
|
keywords: ["paperclip", "plugin", category],
|
||||||
author,
|
author,
|
||||||
license: "MIT",
|
license: "MIT",
|
||||||
dependencies: {
|
...(packedSharedTarball
|
||||||
"@paperclipai/plugin-sdk": "^1.0.0"
|
? {
|
||||||
},
|
pnpm: {
|
||||||
|
overrides: {
|
||||||
|
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
|
...(packedSharedTarball
|
||||||
|
? {
|
||||||
|
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
"@paperclipai/plugin-sdk": sdkDependency,
|
||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
@@ -144,7 +220,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|||||||
declarationMap: true,
|
declarationMap: true,
|
||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
rootDir: "src"
|
rootDir: "."
|
||||||
},
|
},
|
||||||
include: ["src", "tests"],
|
include: ["src", "tests"],
|
||||||
exclude: ["dist", "node_modules"]
|
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(
|
writeFile(
|
||||||
path.join(outputDir, "src", "manifest.ts"),
|
path.join(outputDir, "src", "manifest.ts"),
|
||||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||||
@@ -278,7 +367,7 @@ runWorker(plugin, import.meta.url);
|
|||||||
|
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
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 = {
|
type HealthData = {
|
||||||
status: "ok" | "degraded" | "error";
|
status: "ok" | "degraded" | "error";
|
||||||
@@ -290,11 +379,13 @@ export function DashboardWidget(_props: PluginWidgetProps) {
|
|||||||
const ping = usePluginAction("ping");
|
const ping = usePluginAction("ping");
|
||||||
|
|
||||||
if (loading) return <div>Loading plugin health...</div>;
|
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 (
|
return (
|
||||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
<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>
|
<button onClick={() => void ping()}>Ping Worker</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -342,10 +433,16 @@ pnpm dev:ui # local dev server with hot-reload events
|
|||||||
pnpm test
|
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
|
## Install Into Paperclip
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`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
|
## 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;
|
return outputDir;
|
||||||
}
|
}
|
||||||
@@ -371,7 +468,7 @@ function runCli() {
|
|||||||
const pluginName = process.argv[2];
|
const pluginName = process.argv[2];
|
||||||
if (!pluginName) {
|
if (!pluginName) {
|
||||||
// eslint-disable-next-line no-console
|
// 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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +484,7 @@ function runCli() {
|
|||||||
description: parseArg("--description"),
|
description: parseArg("--description"),
|
||||||
author: parseArg("--author"),
|
author: parseArg("--author"),
|
||||||
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
||||||
|
sdkPath: parseArg("--sdk-path"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// 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.read",
|
||||||
"goals.create",
|
"goals.create",
|
||||||
"goals.update",
|
"goals.update",
|
||||||
"assets.write",
|
|
||||||
"assets.read",
|
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
"metrics.write",
|
"metrics.write",
|
||||||
"plugin.state.read",
|
"plugin.state.read",
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ type OverviewData = {
|
|||||||
};
|
};
|
||||||
lastJob: unknown;
|
lastJob: unknown;
|
||||||
lastWebhook: unknown;
|
lastWebhook: unknown;
|
||||||
lastAsset: unknown;
|
|
||||||
lastProcessResult: unknown;
|
lastProcessResult: unknown;
|
||||||
streamChannels: Record<string, string>;
|
streamChannels: Record<string, string>;
|
||||||
safeCommands: Array<{ key: string; label: string; description: string }>;
|
safeCommands: Array<{ key: string; label: string; description: string }>;
|
||||||
@@ -766,7 +765,6 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
|
|||||||
value={{
|
value={{
|
||||||
lastJob: overview.data?.lastJob ?? null,
|
lastJob: overview.data?.lastJob ?? null,
|
||||||
lastWebhook: overview.data?.lastWebhook ?? null,
|
lastWebhook: overview.data?.lastWebhook ?? null,
|
||||||
lastAsset: overview.data?.lastAsset ?? null,
|
|
||||||
lastProcessResult: overview.data?.lastProcessResult ?? null,
|
lastProcessResult: overview.data?.lastProcessResult ?? null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1340,7 +1338,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
|||||||
const [secretRef, setSecretRef] = useState("");
|
const [secretRef, setSecretRef] = useState("");
|
||||||
const [metricName, setMetricName] = useState("manual");
|
const [metricName, setMetricName] = useState("manual");
|
||||||
const [metricValue, setMetricValue] = useState("1");
|
const [metricValue, setMetricValue] = useState("1");
|
||||||
const [assetContent, setAssetContent] = useState("Kitchen Sink asset demo");
|
|
||||||
const [workspaceId, setWorkspaceId] = useState("");
|
const [workspaceId, setWorkspaceId] = useState("");
|
||||||
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
|
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
|
||||||
const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file.");
|
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 writeMetric = usePluginAction("write-metric");
|
||||||
const httpFetch = usePluginAction("http-fetch");
|
const httpFetch = usePluginAction("http-fetch");
|
||||||
const resolveSecret = usePluginAction("resolve-secret");
|
const resolveSecret = usePluginAction("resolve-secret");
|
||||||
const createAsset = usePluginAction("create-asset");
|
|
||||||
const runProcess = usePluginAction("run-process");
|
const runProcess = usePluginAction("run-process");
|
||||||
const readWorkspaceFile = usePluginAction("read-workspace-file");
|
const readWorkspaceFile = usePluginAction("read-workspace-file");
|
||||||
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
|
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
|
||||||
@@ -1808,7 +1804,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</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))" }}>
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
||||||
<form
|
<form
|
||||||
style={layoutStack}
|
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" />
|
<input style={inputStyle} value={secretRef} onChange={(event) => setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
|
||||||
<button type="submit" style={buttonStyle}>Resolve secret ref</button>
|
<button type="submit" style={buttonStyle}>Resolve secret ref</button>
|
||||||
</form>
|
</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
|
<form
|
||||||
style={layoutStack}
|
style={layoutStack}
|
||||||
onSubmit={(event) => {
|
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 agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||||
const lastJob = await readInstanceState(ctx, "last-job-run");
|
const lastJob = await readInstanceState(ctx, "last-job-run");
|
||||||
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
||||||
const lastAsset = await readInstanceState(ctx, "last-asset");
|
|
||||||
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
|
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
|
||||||
return {
|
return {
|
||||||
pluginId: PLUGIN_ID,
|
pluginId: PLUGIN_ID,
|
||||||
@@ -281,7 +280,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
|||||||
},
|
},
|
||||||
lastJob,
|
lastJob,
|
||||||
lastWebhook,
|
lastWebhook,
|
||||||
lastAsset,
|
|
||||||
lastProcessResult,
|
lastProcessResult,
|
||||||
streamChannels: STREAM_CHANNELS,
|
streamChannels: STREAM_CHANNELS,
|
||||||
safeCommands: SAFE_COMMANDS,
|
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) => {
|
ctx.actions.register("run-process", async (params) => {
|
||||||
const config = await getConfig(ctx);
|
const config = await getConfig(ctx);
|
||||||
const companyId = getCurrentCompanyId(params);
|
const companyId = getCurrentCompanyId(params);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Official TypeScript SDK for Paperclip plugin authors.
|
Official TypeScript SDK for Paperclip plugin authors.
|
||||||
|
|
||||||
- **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle
|
- **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
|
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
|
||||||
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
|
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
|
||||||
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
|
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
|
||||||
@@ -15,10 +15,9 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
|
|||||||
| Import | Purpose |
|
| Import | Purpose |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
| `@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/hooks` | Hooks only |
|
||||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
| `@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/testing` | `createTestHarness` for unit/integration tests |
|
||||||
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
||||||
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
|
| `@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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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. |
|
| `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. |
|
| `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.
|
**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.
|
**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
|
## Scheduled (recurring) jobs
|
||||||
|
|
||||||
@@ -302,8 +305,6 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||||||
| | `issues.create` |
|
| | `issues.create` |
|
||||||
| | `issues.update` |
|
| | `issues.update` |
|
||||||
| | `issue.comments.create` |
|
| | `issue.comments.create` |
|
||||||
| | `assets.write` |
|
|
||||||
| | `assets.read` |
|
|
||||||
| | `activity.log.write` |
|
| | `activity.log.write` |
|
||||||
| | `metrics.write` |
|
| | `metrics.write` |
|
||||||
| **Instance** | `instance.settings.register` |
|
| **Instance** | `instance.settings.register` |
|
||||||
@@ -333,14 +334,15 @@ Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
|||||||
## UI quick start
|
## UI quick start
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui";
|
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
export function DashboardWidget() {
|
export function DashboardWidget() {
|
||||||
const { data } = usePluginData<{ status: string }>("health");
|
const { data } = usePluginData<{ status: string }>("health");
|
||||||
const ping = usePluginAction("ping");
|
const ping = usePluginAction("ping");
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ display: "grid", gap: 8 }}>
|
||||||
<MetricCard label="Health" value={data?.status ?? "unknown"} />
|
<strong>Health</strong>
|
||||||
|
<div>{data?.status ?? "unknown"}</div>
|
||||||
<button onClick={() => void ping()}>Ping</button>
|
<button onClick={() => void ping()}>Ping</button>
|
||||||
</div>
|
</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 }`.
|
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui";
|
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
interface SyncStatus {
|
interface SyncStatus {
|
||||||
lastSyncAt: string;
|
lastSyncAt: string;
|
||||||
@@ -367,12 +369,12 @@ export function SyncStatusWidget({ context }: PluginWidgetProps) {
|
|||||||
companyId: context.companyId,
|
companyId: context.companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) return <Spinner />;
|
if (loading) return <div>Loading…</div>;
|
||||||
if (error) return <StatusBadge label={error.message} status="error" />;
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>Synced {data!.syncedCount} items</p>
|
||||||
<p>Last sync: {data!.lastSyncAt}</p>
|
<p>Last sync: {data!.lastSyncAt}</p>
|
||||||
<button onClick={refresh}>Refresh</button>
|
<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.
|
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`.
|
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.
|
||||||
|
|
||||||
#### `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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Slot component props
|
### Slot component props
|
||||||
|
|
||||||
@@ -579,7 +490,7 @@ Example detail tab with entity context:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
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) {
|
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
||||||
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
|
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
|
||||||
@@ -587,13 +498,18 @@ export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
|||||||
companyId: context.companyId,
|
companyId: context.companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) return <Spinner />;
|
if (loading) return <div>Loading…</div>;
|
||||||
if (!data) return <p>No metrics available.</p>;
|
if (!data) return <p>No metrics available.</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyValueList
|
<dl>
|
||||||
pairs={Object.entries(data).map(([label, value]) => ({ label, value }))}
|
{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
|
```tsx
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
|
||||||
Spinner,
|
|
||||||
useHostContext,
|
useHostContext,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
} from "@paperclipai/plugin-sdk/ui";
|
} from "@paperclipai/plugin-sdk/ui";
|
||||||
@@ -730,7 +644,7 @@ export function SyncToolbarButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<>
|
||||||
<button type="button" onClick={() => setOpen(true)}>
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
Sync
|
Sync
|
||||||
</button>
|
</button>
|
||||||
@@ -757,13 +671,13 @@ export function SyncToolbarButton() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
||||||
{submitting ? <Spinner size="sm" /> : "Run sync"}
|
{submitting ? "Running…" : "Run sync"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</ErrorBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -28,10 +28,6 @@
|
|||||||
"types": "./dist/ui/types.d.ts",
|
"types": "./dist/ui/types.d.ts",
|
||||||
"import": "./dist/ui/types.js"
|
"import": "./dist/ui/types.js"
|
||||||
},
|
},
|
||||||
"./ui/components": {
|
|
||||||
"types": "./dist/ui/components.d.ts",
|
|
||||||
"import": "./dist/ui/components.js"
|
|
||||||
},
|
|
||||||
"./testing": {
|
"./testing": {
|
||||||
"types": "./dist/testing.d.ts",
|
"types": "./dist/testing.d.ts",
|
||||||
"import": "./dist/testing.js"
|
"import": "./dist/testing.js"
|
||||||
@@ -75,10 +71,6 @@
|
|||||||
"types": "./dist/ui/types.d.ts",
|
"types": "./dist/ui/types.d.ts",
|
||||||
"import": "./dist/ui/types.js"
|
"import": "./dist/ui/types.js"
|
||||||
},
|
},
|
||||||
"./ui/components": {
|
|
||||||
"types": "./dist/ui/components.d.ts",
|
|
||||||
"import": "./dist/ui/components.js"
|
|
||||||
},
|
|
||||||
"./testing": {
|
"./testing": {
|
||||||
"types": "./dist/testing.d.ts",
|
"types": "./dist/testing.d.ts",
|
||||||
"import": "./dist/testing.js"
|
"import": "./dist/testing.js"
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
|||||||
const uiExternal = [
|
const uiExternal = [
|
||||||
"@paperclipai/plugin-sdk/ui",
|
"@paperclipai/plugin-sdk/ui",
|
||||||
"@paperclipai/plugin-sdk/ui/hooks",
|
"@paperclipai/plugin-sdk/ui/hooks",
|
||||||
"@paperclipai/plugin-sdk/ui/components",
|
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
@@ -84,7 +83,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
|||||||
target: "node20",
|
target: "node20",
|
||||||
sourcemap,
|
sourcemap,
|
||||||
minify,
|
minify,
|
||||||
external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"],
|
external: ["react", "react-dom"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const esbuildManifest: EsbuildLikeOptions = {
|
const esbuildManifest: EsbuildLikeOptions = {
|
||||||
@@ -119,7 +118,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
|||||||
sourcemap,
|
sourcemap,
|
||||||
entryFileNames: "worker.js",
|
entryFileNames: "worker.js",
|
||||||
},
|
},
|
||||||
external: ["@paperclipai/plugin-sdk", "react", "react-dom"],
|
external: ["react", "react-dom"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const rollupManifest: RollupLikeConfig = {
|
const rollupManifest: RollupLikeConfig = {
|
||||||
|
|||||||
@@ -118,12 +118,6 @@ export interface HostServices {
|
|||||||
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
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`. */
|
/** Provides `activity.log`. */
|
||||||
activity: {
|
activity: {
|
||||||
log(params: {
|
log(params: {
|
||||||
@@ -274,10 +268,6 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
|||||||
// Secrets
|
// Secrets
|
||||||
"secrets.resolve": "secrets.read-ref",
|
"secrets.resolve": "secrets.read-ref",
|
||||||
|
|
||||||
// Assets
|
|
||||||
"assets.upload": "assets.write",
|
|
||||||
"assets.getUrl": "assets.read",
|
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
"activity.log": "activity.log.write",
|
"activity.log": "activity.log.write",
|
||||||
|
|
||||||
@@ -428,14 +418,6 @@ export function createHostClientHandlers(
|
|||||||
return services.secrets.resolve(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
|
||||||
"activity.log": gated("activity.log", async (params) => {
|
"activity.log": gated("activity.log", async (params) => {
|
||||||
return services.activity.log(params);
|
return services.activity.log(params);
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ export type {
|
|||||||
PluginLaunchersClient,
|
PluginLaunchersClient,
|
||||||
PluginHttpClient,
|
PluginHttpClient,
|
||||||
PluginSecretsClient,
|
PluginSecretsClient,
|
||||||
PluginAssetsClient,
|
|
||||||
PluginActivityClient,
|
PluginActivityClient,
|
||||||
PluginActivityLogEntry,
|
PluginActivityLogEntry,
|
||||||
PluginStateClient,
|
PluginStateClient,
|
||||||
|
|||||||
@@ -495,16 +495,6 @@ export interface WorkerToHostMethods {
|
|||||||
result: string,
|
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
|
||||||
"activity.log": [
|
"activity.log": [
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -136,8 +136,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
const state = new Map<string, unknown>();
|
const state = new Map<string, unknown>();
|
||||||
const entities = new Map<string, PluginEntityRecord>();
|
const entities = new Map<string, PluginEntityRecord>();
|
||||||
const entityExternalIndex = new Map<string, string>();
|
const entityExternalIndex = new Map<string, string>();
|
||||||
const assets = new Map<string, { contentType: string; data: Uint8Array }>();
|
|
||||||
|
|
||||||
const companies = new Map<string, Company>();
|
const companies = new Map<string, Company>();
|
||||||
const projects = new Map<string, Project>();
|
const projects = new Map<string, Project>();
|
||||||
const issues = new Map<string, Issue>();
|
const issues = new Map<string, Issue>();
|
||||||
@@ -207,19 +205,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
return `resolved:${secretRef}`;
|
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: {
|
activity: {
|
||||||
async log(entry) {
|
async log(entry) {
|
||||||
requireCapability(manifest, capabilitySet, "activity.log.write");
|
requireCapability(manifest, capabilitySet, "activity.log.write");
|
||||||
|
|||||||
@@ -452,34 +452,6 @@ export interface PluginSecretsClient {
|
|||||||
resolve(secretRef: string): Promise<string>;
|
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.
|
* Input for writing a plugin activity log entry.
|
||||||
*
|
*
|
||||||
@@ -1069,9 +1041,6 @@ export interface PluginContext {
|
|||||||
/** Resolve secret references. Requires `secrets.read-ref`. */
|
/** Resolve secret references. Requires `secrets.read-ref`. */
|
||||||
secrets: PluginSecretsClient;
|
secrets: PluginSecretsClient;
|
||||||
|
|
||||||
/** Read and write assets. Requires `assets.read` / `assets.write`. */
|
|
||||||
assets: PluginAssetsClient;
|
|
||||||
|
|
||||||
/** Write activity log entries. Requires `activity.log.write`. */
|
/** Write activity log entries. Requires `activity.log.write`. */
|
||||||
activity: PluginActivityClient;
|
activity: PluginActivityClient;
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ import { getSdkUiRuntimeValue } from "./runtime.js";
|
|||||||
* companyId: context.companyId,
|
* companyId: context.companyId,
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* if (loading) return <Spinner />;
|
* if (loading) return <div>Loading…</div>;
|
||||||
* if (error) return <div>Error: {error.message}</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
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
||||||
* import {
|
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||||
* usePluginData,
|
|
||||||
* usePluginAction,
|
|
||||||
* useHostContext,
|
|
||||||
* MetricCard,
|
|
||||||
* StatusBadge,
|
|
||||||
* Spinner,
|
|
||||||
* } from "@paperclipai/plugin-sdk/ui";
|
|
||||||
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||||
*
|
*
|
||||||
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||||
@@ -28,12 +21,13 @@
|
|||||||
* });
|
* });
|
||||||
* const resync = usePluginAction("resync");
|
* const resync = usePluginAction("resync");
|
||||||
*
|
*
|
||||||
* if (loading) return <Spinner />;
|
* if (loading) return <div>Loading…</div>;
|
||||||
* if (error) return <div>Error: {error.message}</div>;
|
* if (error) return <div>Error: {error.message}</div>;
|
||||||
*
|
*
|
||||||
* return (
|
* return (
|
||||||
* <div>
|
* <div style={{ display: "grid", gap: 8 }}>
|
||||||
* <MetricCard label="Synced Issues" value={data!.syncedCount} />
|
* <strong>Synced Issues</strong>
|
||||||
|
* <div>{data!.syncedCount}</div>
|
||||||
* <button onClick={() => resync({ companyId: context.companyId })}>
|
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||||
* Resync Now
|
* Resync Now
|
||||||
* </button>
|
* </button>
|
||||||
@@ -91,40 +85,3 @@ export type {
|
|||||||
PluginCommentContextMenuItemProps,
|
PluginCommentContextMenuItemProps,
|
||||||
PluginSettingsPageProps,
|
PluginSettingsPageProps,
|
||||||
} from "./types.js";
|
} 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: {
|
activity: {
|
||||||
async log(entry): Promise<void> {
|
async log(entry): Promise<void> {
|
||||||
await callHost("activity.log", {
|
await callHost("activity.log", {
|
||||||
|
|||||||
@@ -331,8 +331,6 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
"agent.sessions.list",
|
"agent.sessions.list",
|
||||||
"agent.sessions.send",
|
"agent.sessions.send",
|
||||||
"agent.sessions.close",
|
"agent.sessions.close",
|
||||||
"assets.write",
|
|
||||||
"assets.read",
|
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
"metrics.write",
|
"metrics.write",
|
||||||
// Plugin State
|
// Plugin State
|
||||||
|
|||||||
@@ -61,15 +61,11 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||||||
"activity.get": ["activity.read"],
|
"activity.get": ["activity.read"],
|
||||||
"costs.list": ["costs.read"],
|
"costs.list": ["costs.read"],
|
||||||
"costs.get": ["costs.read"],
|
"costs.get": ["costs.read"],
|
||||||
"assets.list": ["assets.read"],
|
|
||||||
"assets.get": ["assets.read"],
|
|
||||||
|
|
||||||
// Data write operations
|
// Data write operations
|
||||||
"issues.create": ["issues.create"],
|
"issues.create": ["issues.create"],
|
||||||
"issues.update": ["issues.update"],
|
"issues.update": ["issues.update"],
|
||||||
"issue.comments.create": ["issue.comments.create"],
|
"issue.comments.create": ["issue.comments.create"],
|
||||||
"assets.upload": ["assets.write"],
|
|
||||||
"assets.delete": ["assets.write"],
|
|
||||||
"activity.log": ["activity.log.write"],
|
"activity.log": ["activity.log.write"],
|
||||||
"metrics.write": ["metrics.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: {
|
activity: {
|
||||||
async log(params) {
|
async log(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
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 { PluginSlotMount } from "@/plugins/slots";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { NotFoundPage } from "./NotFound";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company-context plugin page. Renders a plugin's `page` slot at
|
* Company-context plugin page. Renders a plugin's `page` slot at
|
||||||
@@ -25,12 +26,18 @@ export function PluginPage() {
|
|||||||
}>();
|
}>();
|
||||||
const { companies, selectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
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(() => {
|
const resolvedCompanyId = useMemo(() => {
|
||||||
if (!routeCompanyPrefix) return selectedCompanyId ?? null;
|
if (routeCompany) return routeCompany.id;
|
||||||
const requested = routeCompanyPrefix.toUpperCase();
|
if (routeCompanyPrefix) return null;
|
||||||
return companies.find((c) => c.issuePrefix.toUpperCase() === requested)?.id ?? selectedCompanyId ?? null;
|
return selectedCompanyId ?? null;
|
||||||
}, [companies, routeCompanyPrefix, selectedCompanyId]);
|
}, [routeCompany, routeCompanyPrefix, selectedCompanyId]);
|
||||||
|
|
||||||
const companyPrefix = useMemo(
|
const companyPrefix = useMemo(
|
||||||
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
|
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
|
||||||
@@ -92,6 +99,9 @@ export function PluginPage() {
|
|||||||
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
||||||
|
|
||||||
if (!resolvedCompanyId) {
|
if (!resolvedCompanyId) {
|
||||||
|
if (hasInvalidCompanyPrefix) {
|
||||||
|
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
|
<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 (!pageSlot) {
|
||||||
|
if (pluginRoutePath) {
|
||||||
|
return <NotFoundPage scope="board" />;
|
||||||
|
}
|
||||||
// No page slot: redirect to plugin settings where plugin info is always shown
|
// No page slot: redirect to plugin settings where plugin info is always shown
|
||||||
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
|
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
|
||||||
return <Navigate to={settingsPath} replace />;
|
return <Navigate to={settingsPath} replace />;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
|
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
import {
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
@@ -60,61 +59,11 @@ export function initPluginBridge(
|
|||||||
react,
|
react,
|
||||||
reactDom,
|
reactDom,
|
||||||
sdkUi: {
|
sdkUi: {
|
||||||
// Bridge hooks
|
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
usePluginToast,
|
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":
|
case "sdk-ui":
|
||||||
source = `
|
source = `
|
||||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
|
||||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
|
||||||
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 };
|
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -294,8 +288,6 @@ function rewriteBareSpecifiers(source: string): string {
|
|||||||
"'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`,
|
"'@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/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/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`,
|
"'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`,
|
||||||
'"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`,
|
'"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`,
|
||||||
|
|||||||
Reference in New Issue
Block a user