From 30888759f2b37ff787013a4b6768693d5dcbb7cd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 10:40:21 -0500 Subject: [PATCH] Clarify plugin authoring and external dev workflow --- doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 154 ++++++++++++++++++ doc/plugins/PLUGIN_SPEC.md | 3 + .../plugins/create-paperclip-plugin/README.md | 16 +- .../create-paperclip-plugin/src/index.ts | 120 ++++++++++++-- .../plugin-authoring-smoke-example/.gitignore | 2 + .../plugin-authoring-smoke-example/README.md | 23 +++ .../esbuild.config.mjs | 17 ++ .../package.json | 44 +++++ .../rollup.config.mjs | 28 ++++ .../src/manifest.ts | 32 ++++ .../src/ui/index.tsx | 23 +++ .../src/worker.ts | 27 +++ .../tests/plugin.spec.ts | 20 +++ .../tsconfig.json | 27 +++ .../vitest.config.ts | 8 + .../src/manifest.ts | 2 - .../src/ui/index.tsx | 22 +-- .../plugin-kitchen-sink-example/src/worker.ts | 20 --- packages/plugins/sdk/README.md | 148 ++++------------- packages/plugins/sdk/package.json | 8 - packages/plugins/sdk/src/bundlers.ts | 5 +- .../plugins/sdk/src/host-client-factory.ts | 18 -- packages/plugins/sdk/src/index.ts | 1 - packages/plugins/sdk/src/protocol.ts | 10 -- packages/plugins/sdk/src/testing.ts | 15 -- packages/plugins/sdk/src/types.ts | 31 ---- packages/plugins/sdk/src/ui/hooks.ts | 4 +- packages/plugins/sdk/src/ui/index.ts | 53 +----- packages/plugins/sdk/src/worker-rpc-host.ts | 20 --- packages/shared/src/constants.ts | 2 - .../services/plugin-capability-validator.ts | 4 - server/src/services/plugin-host-services.ts | 11 -- skills/paperclip-create-plugin/SKILL.md | 101 ++++++++++++ ui/src/pages/PluginPage.tsx | 21 ++- ui/src/plugins/bridge-init.ts | 51 ------ ui/src/plugins/slots.tsx | 12 +- 36 files changed, 693 insertions(+), 410 deletions(-) create mode 100644 doc/plugins/PLUGIN_AUTHORING_GUIDE.md create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/.gitignore create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/README.md create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/package.json create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json create mode 100644 packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts create mode 100644 skills/paperclip-create-plugin/SKILL.md diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md new file mode 100644 index 00000000..a345bea0 --- /dev/null +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -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/ +``` + +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 typecheck +pnpm --filter test +pnpm --filter build +``` + +If you changed host integration too, also run: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 65fabac0..f3ec6473 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -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. diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md index 46519da1..24294122 100644 --- a/packages/plugins/create-paperclip-plugin/README.md +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -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 diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts index 6d0e6c2d..d5aec878 100644 --- a/packages/plugins/create-paperclip-plugin/src/index.ts +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -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
Loading plugin health...
; - if (error) return ; + if (error) return
Plugin error: {error.message}
; return (
- + ${displayName} +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
); @@ -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 [--template default|connector|workspace] [--output ]"); + console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ] [--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 diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/README.md b/packages/plugins/examples/plugin-authoring-smoke-example/README.md new file mode 100644 index 00000000..50099ad4 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/README.md @@ -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. diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs new file mode 100644 index 00000000..b5cfd36e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs @@ -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()]); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json new file mode 100644 index 00000000..be91978b --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -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" + } +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs new file mode 100644 index 00000000..ccee40a7 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs @@ -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); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts new file mode 100644 index 00000000..eb1c1efe --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts @@ -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; diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx new file mode 100644 index 00000000..2b0cabeb --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx @@ -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("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ Plugin Authoring Smoke Example +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts new file mode 100644 index 00000000..16ef652e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts @@ -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); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts new file mode 100644 index 00000000..8dddda88 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts @@ -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); + }); +}); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json new file mode 100644 index 00000000..a697519e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json @@ -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" + ] +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts new file mode 100644 index 00000000..649a293e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index b3348a8c..bb3215c2 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -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", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index 53bf0b9e..826dd832 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -71,7 +71,6 @@ type OverviewData = { }; lastJob: unknown; lastWebhook: unknown; - lastAsset: unknown; lastProcessResult: unknown; streamChannels: Record; 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(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; -
+
setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
-
{ - 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) })); - }} - > - Assets -