[codex] Improve local plugin development workflow (#5821)

## Thinking Path

> - Paperclip is the control plane for autonomous AI-agent companies.
> - Plugins are the extension point for adding capabilities without
expanding the core product surface.
> - Local plugin development needed a tighter CLI-first loop so plugin
authors can scaffold, run, install, inspect, and reload plugins without
reaching into internal package paths.
> - The server plugin install path also needed local-path handling that
keeps plugin identity, dashboard routes, and development watchers
coherent.
> - This pull request adds the CLI scaffold/install workflow, fixes the
server and SDK edge cases that blocked that loop, and updates the
agent-facing plugin creation skill and docs.
> - The benefit is that contributors can develop plugins from local
folders with a documented, repeatable happy path.

## What Changed

- Added `paperclipai plugin init` coverage and CLI wiring for local
plugin scaffolding.
- Improved local plugin install handling, plugin key route resolution,
dashboard capability behavior, and dev watcher startup/reload behavior.
- Fixed plugin SDK worker entrypoint validation for symlinked package
layouts.
- Added targeted tests for plugin init, server plugin authz/watcher
behavior, SDK worker host validation, and the authoring smoke example.
- Added a short local plugin development guide and refreshed the plugin
authoring guide plus `paperclip-create-plugin` skill instructions.

## Verification

- `pnpm run preflight:workspace-links && pnpm --filter
@paperclipai/plugin-sdk build && pnpm --filter
@paperclipai/create-paperclip-plugin typecheck && pnpm --filter
paperclipai typecheck && pnpm --filter @paperclipai/plugin-sdk typecheck
&& pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run --project paperclipai
cli/src/__tests__/plugin-init.test.ts`
- `pnpm exec vitest run --project @paperclipai/plugin-sdk
packages/plugins/sdk/tests/worker-rpc-host.test.ts`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/plugin-dev-watcher.test.ts --pool=forks
--poolOptions.forks.isolate=true`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/plugin-routes-authz.test.ts --pool=forks
--poolOptions.forks.isolate=true`
- `pnpm --dir packages/plugins/examples/plugin-authoring-smoke-example
test`
- Confirmed `pnpm-lock.yaml` is not included in the PR diff.

## Risks

- Medium risk: this touches plugin install routing, CLI command
behavior, and the local development watcher.
- Local path plugin installs execute trusted local code by design; the
new docs call out that trust boundary.
- No database migrations are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled local shell and git
workflow, medium reasoning effort. Context window details were not
exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

UI screenshots: not applicable; this PR changes CLI/server/plugin docs
and tests, not board UI rendering.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-12 17:38:24 -05:00
committed by GitHub
parent 0808b388ee
commit b947a7d76c
19 changed files with 875 additions and 151 deletions
+164
View File
@@ -0,0 +1,164 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
}));
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
const actual =
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
);
return {
...actual,
scaffoldPluginProject: mocks.scaffoldPluginProject,
};
});
import {
buildPluginInstallRequest,
buildPluginInitNextCommands,
buildPluginInitScaffoldOptions,
registerPluginCommands,
} from "../commands/client/plugin.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin init", () => {
beforeEach(() => {
mocks.scaffoldPluginProject.mockClear();
});
it("maps package name and flags to scaffolder options", () => {
const cwd = path.resolve("/tmp/paperclip-cli-test");
const options = buildPluginInitScaffoldOptions(
"@acme/plugin-linear",
{
output: "plugins",
template: "connector",
category: "automation",
displayName: "Linear Bridge",
description: "Syncs Linear issues",
author: "Acme",
sdkPath: "../paperclip/packages/plugins/sdk",
},
cwd,
);
expect(options).toEqual({
pluginName: "@acme/plugin-linear",
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
template: "connector",
category: "automation",
displayName: "Linear Bridge",
description: "Syncs Linear issues",
author: "Acme",
sdkPath: "../paperclip/packages/plugins/sdk",
});
});
it("builds exact next commands using the scaffold path", () => {
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
"cd '/tmp/acme plugin'",
"pnpm install",
"pnpm dev",
"paperclipai plugin install '/tmp/acme plugin'",
]);
});
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerPluginCommands(program);
await program.parseAsync(
[
"plugin",
"init",
"demo-plugin",
"--output",
"/tmp/paperclip-init-output",
"--template",
"workspace",
"--category",
"workspace",
"--display-name",
"Demo Plugin",
"--description",
"Demo description",
"--author",
"Paperclip",
"--sdk-path",
"/repo/packages/plugins/sdk",
],
{ from: "user" },
);
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
pluginName: "demo-plugin",
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
template: "workspace",
category: "workspace",
displayName: "Demo Plugin",
description: "Demo description",
author: "Paperclip",
sdkPath: "/repo/packages/plugins/sdk",
});
});
});
describe("plugin install", () => {
it("resolves an existing relative local path to an absolute local install request", () => {
const cwd = makeTempDir();
const pluginDir = path.join(cwd, "demo-plugin");
fs.mkdirSync(pluginDir);
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
packageName: pluginDir,
version: undefined,
isLocalPath: true,
});
});
it("keeps an absolute local path absolute and marks it as local", () => {
const pluginDir = path.join(makeTempDir(), "demo-plugin");
fs.mkdirSync(pluginDir);
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
packageName: pluginDir,
version: undefined,
isLocalPath: true,
});
});
it("preserves npm package installs when no local path exists", () => {
expect(
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
cwd: makeTempDir(),
}),
).toEqual({
packageName: "@acme/plugin-linear",
version: "1.2.3",
isLocalPath: false,
});
});
});
+185 -25
View File
@@ -1,5 +1,11 @@
import path from "node:path";
import { Command } from "commander";
import { existsSync } from "node:fs";
import { Command, Option } from "commander";
import {
scaffoldPluginProject,
shellQuote,
type ScaffoldPluginOptions,
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
import pc from "picocolors";
import {
addCommonClientOptions,
@@ -39,28 +45,101 @@ interface PluginInstallOptions extends BaseClientOptions {
version?: string;
}
interface PluginInstallRequest {
packageName: string;
version?: string;
isLocalPath: boolean;
}
interface PluginUninstallOptions extends BaseClientOptions {
force?: boolean;
}
interface PluginInitOptions extends BaseClientOptions {
output?: string;
template?: ScaffoldPluginOptions["template"];
category?: ScaffoldPluginOptions["category"];
displayName?: string;
description?: string;
author?: string;
sdkPath?: string;
}
interface PluginInitResult {
outputDir: string;
nextCommands: string[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function expandHomePath(packageArg: string): string {
if (!packageArg.startsWith("~")) return packageArg;
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
}
function hasLocalPathSyntax(packageArg: string): boolean {
return (
path.isAbsolute(packageArg) ||
packageArg.startsWith("./") ||
packageArg.startsWith("../") ||
packageArg.startsWith("~") ||
packageArg.startsWith(".\\") ||
packageArg.startsWith("..\\")
);
}
function isExistingRelativePath(
packageArg: string,
cwd: string,
pathExists: (targetPath: string) => boolean,
): boolean {
if (packageArg.trim() === "") return false;
if (hasLocalPathSyntax(packageArg)) return false;
return pathExists(path.resolve(cwd, packageArg));
}
/**
* Resolve a local path argument to an absolute path so the server can find the
* plugin on disk regardless of where the user ran the CLI.
*/
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
if (!isLocal) return packageArg;
// Already absolute
if (path.isAbsolute(packageArg)) return packageArg;
// Expand leading ~ to home directory
if (packageArg.startsWith("~")) {
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
return path.resolve(cwd, packageArg);
}
export function buildPluginInstallRequest(
packageArg: string,
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
): PluginInstallRequest {
const cwd = deps.cwd ?? process.cwd();
const pathExists = deps.existsSync ?? existsSync;
const isLocal =
opts.local ||
hasLocalPathSyntax(packageArg) ||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
if (isLocal && opts.version) {
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
}
return path.resolve(process.cwd(), packageArg);
return {
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
version: opts.version,
isLocalPath: Boolean(isLocal),
};
}
export function renderLocalPluginInstallHint(packagePath: string): string {
return [
pc.dim("Local plugin installs run trusted local code from your machine."),
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
].join("\n");
}
function formatPlugin(p: PluginRecord): string {
@@ -87,6 +166,58 @@ function formatPlugin(p: PluginRecord): string {
return parts.join(" ");
}
function packageToDirName(pluginName: string): string {
return pluginName.replace(/^@[^/]+\//, "");
}
export function buildPluginInitScaffoldOptions(
packageName: string,
opts: PluginInitOptions,
cwd = process.cwd(),
): ScaffoldPluginOptions {
const outputRoot = path.resolve(cwd, opts.output ?? ".");
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
return {
pluginName: packageName,
outputDir,
template: opts.template,
category: opts.category,
displayName: opts.displayName,
description: opts.description,
author: opts.author,
sdkPath: opts.sdkPath,
};
}
export function buildPluginInitNextCommands(outputDir: string): string[] {
const quotedOutputDir = shellQuote(outputDir);
return [
`cd ${quotedOutputDir}`,
"pnpm install",
"pnpm dev",
`paperclipai plugin install ${quotedOutputDir}`,
];
}
export function renderPluginInitSuccess(result: PluginInitResult): string {
return [
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
"",
"Next commands:",
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
].join("\n");
}
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
const outputDir = scaffoldPluginProject(scaffoldOptions);
return {
outputDir,
nextCommands: buildPluginInitNextCommands(outputDir),
};
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
@@ -94,6 +225,43 @@ function formatPlugin(p: PluginRecord): string {
export function registerPluginCommands(program: Command): void {
const plugin = program.command("plugin").description("Plugin lifecycle management");
// -------------------------------------------------------------------------
// plugin init <package-name>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("init <packageName>")
.description("Scaffold a local Paperclip plugin project")
.option("--output <dir>", "Directory to create the plugin folder in")
.addOption(
new Option("--template <template>", "Starter template")
.choices(["default", "connector", "workspace", "environment"])
.default("default"),
)
.addOption(
new Option("--category <category>", "Manifest category")
.choices(["connector", "workspace", "automation", "ui", "environment"]),
)
.option("--display-name <name>", "Manifest display name")
.option("--description <description>", "Manifest description")
.option("--author <author>", "Manifest author")
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
.action((packageName: string, opts: PluginInitOptions) => {
try {
const result = runPluginInitCommand(packageName, opts);
if (opts.json) {
printOutput(result, { json: true });
return;
}
console.log(renderPluginInitSuccess(result));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin list
// -------------------------------------------------------------------------
@@ -147,31 +315,19 @@ export function registerPluginCommands(program: Command): void {
try {
const ctx = resolveCommandContext(opts);
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
const isLocal =
opts.local ||
packageArg.startsWith("./") ||
packageArg.startsWith("../") ||
packageArg.startsWith("/") ||
packageArg.startsWith("~");
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
const installRequest = buildPluginInstallRequest(packageArg, opts);
if (!ctx.json) {
console.log(
pc.dim(
isLocal
? `Installing plugin from local path: ${resolvedPackage}`
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
installRequest.isLocalPath
? `Installing plugin from local path: ${installRequest.packageName}`
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
),
);
}
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
packageName: resolvedPackage,
version: opts.version,
isLocalPath: isLocal,
});
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
if (ctx.json) {
printOutput(installedPlugin, { json: true });
@@ -192,6 +348,10 @@ export function registerPluginCommands(program: Command): void {
if (installedPlugin.lastError) {
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
}
if (installRequest.isLocalPath) {
console.log(renderLocalPluginInstallHint(installRequest.packageName));
}
} catch (err) {
handleCommandError(err);
}
+1 -1
View File
@@ -4,5 +4,5 @@
"outDir": "dist",
"rootDir": ".."
},
"include": ["src", "../packages/shared/src"]
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
}
+136
View File
@@ -0,0 +1,136 @@
# Local Plugin Development
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
## Prerequisites
- Node.js 22+ and `pnpm`.
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
## The five steps
```bash
# 1. Start Paperclip locally
pnpm paperclipai run
# 2. Scaffold a plugin outside the Paperclip repo
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
# 3. Install dependencies and start the watch build
cd ~/dev/paperclip-plugins/hello-plugin
pnpm install
pnpm dev
# 4. In another terminal, install the plugin from its absolute path
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
# 5. Confirm it loaded
paperclipai plugin list
paperclipai plugin inspect acme.hello-plugin
```
That's the loop. The rest of this page explains what each step does and what to expect when you edit code.
### 1. Start Paperclip
```bash
pnpm paperclipai run
```
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
### 2. Scaffold the plugin
```bash
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
```
This creates `~/dev/paperclip-plugins/hello-plugin/` with `src/manifest.ts`, `src/worker.ts`, `src/ui/index.tsx`, an esbuild watch config, a Vitest config, and a snapshot of `@paperclipai/plugin-sdk` from your local Paperclip checkout. You can run the package and tests without publishing anything to npm.
Useful flags:
- `--template <default|connector|workspace|environment>` — starter shape.
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
- `--display-name`, `--description`, `--author` — manifest metadata.
- `--sdk-path <absolute-path>` — point at a specific `packages/plugins/sdk` checkout if you have more than one.
When `plugin init` finishes, it prints the next four commands literally. You can copy them.
### 3. Install dependencies and run the watch build
```bash
cd ~/dev/paperclip-plugins/hello-plugin
pnpm install
pnpm dev
```
`pnpm dev` runs `esbuild --watch` against the plugin source and emits `dist/manifest.js`, `dist/worker.js`, and `dist/ui/`. Leave it running. Every time you save, esbuild rebuilds the affected output file.
If your plugin has UI and you want a browser-side dev server with hot module replacement during local UI iteration, run `pnpm dev:ui` in a second terminal. It serves `dist/ui/` on `http://127.0.0.1:4177`. This is optional; Paperclip can load the built UI directly from `dist/ui/` without it.
### 4. Install from the absolute path
```bash
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
```
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
You will see a confirmation like:
```
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
✓ Installed acme.hello-plugin v0.1.0 (ready)
Local plugin installs run trusted local code from your machine.
Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
Paperclip watches rebuilt dist output and reloads the plugin worker.
```
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
### 5. Inspect
```bash
paperclipai plugin list
paperclipai plugin inspect acme.hello-plugin
```
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
## Reload semantics, honestly
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
What that means in practice:
- **Worker code:** save a `.ts` file → esbuild rewrites `dist/worker.js` → Paperclip debounces ~500ms and restarts the plugin worker. The next worker call uses the new code. There is no in-process hot module replacement for worker code; it is a worker restart.
- **Manifest:** save `src/manifest.ts``dist/manifest.js` rewrites → the worker restarts and the host re-reads the manifest.
- **Plugin UI:** save a `.tsx` file → esbuild rewrites `dist/ui/` → Paperclip reloads the UI bundle on its next mount. To get HMR during UI iteration, run `pnpm dev:ui` and point at the dev server with `devUiUrl` in your manifest while developing.
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
The server never compiles plugin source for you. The package's own build scripts own that step.
## Local path plugins vs npm packages
Both go through the same install endpoint, but they mean different things:
- **Local path plugins are trusted local code.** Paperclip executes worker code from disk under the same trust boundary as the rest of the running instance. This is meant for developing or operating a plugin against a checkout you control. There is no signature check, no sandboxing of worker code, and no provenance metadata beyond the path. Do not install local-path plugins you did not write.
- **npm packages are the deployable artifact.** `paperclipai plugin install @acme/plugin-foo` (optionally `--version 1.2.3`) installs from your configured npm registry, version-pins, and produces an install record that other operators can reproduce. Ship plugins this way.
When you are done iterating locally, publish the package and reinstall the npm-package form so the install reflects what you will ship.
## Common things to do next
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
## Troubleshooting
- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect <key>` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
+10 -26
View File
@@ -4,6 +4,8 @@ This guide describes the current, implemented way to create a Paperclip plugin i
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.
> **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install <path>`) end to end. Come back here for the full manifest surface, worker capabilities, and UI components.
## Current reality
- Treat plugin workers and plugin UI as trusted code.
@@ -20,23 +22,13 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
## Scaffold a plugin
Use the scaffold package:
Use the CLI scaffold command:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
paperclipai plugin init @yourscope/plugin-name --output /absolute/path/to/plugin-repos
```
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:
That creates `<output>/plugin-name/` with:
- `src/manifest.ts`
- `src/worker.ts`
@@ -47,11 +39,13 @@ That creates a package with:
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.
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. Pass `--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk` if you have more than one Paperclip checkout.
## Recommended local workflow
## Local development workflow
From the generated plugin folder:
See the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) for the full happy path (`pnpm dev``paperclipai plugin install <absolute-path>``paperclipai plugin list`) and reload semantics.
Minimum verification from the generated plugin folder:
```bash
pnpm install
@@ -60,16 +54,6 @@ 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:
@@ -62,6 +62,11 @@ function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
export function shellQuote(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
function formatFileDependency(absPath: string): string {
return `file:${toPosixPath(path.resolve(absPath))}`;
}
@@ -312,7 +317,8 @@ const manifest: PaperclipPluginManifestV1 = {
capabilities: [
"environment.drivers.register",
"plugin.state.read",
"plugin.state.write"
"plugin.state.write",
"ui.dashboardWidget.register"
],
entrypoints: {
worker: "./dist/worker.js",
@@ -467,6 +473,11 @@ const BASE_PARAMS = {
};
describe("environment plugin scaffold", () => {
it("declares capabilities for its manifest features", () => {
expect(manifest.capabilities).toContain("environment.drivers.register");
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
});
it("validates config", async () => {
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
@@ -533,7 +544,8 @@ const manifest: PaperclipPluginManifestV1 = {
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write"
"plugin.state.write",
"ui.dashboardWidget.register"
],
entrypoints: {
worker: "./dist/worker.js",
@@ -623,6 +635,11 @@ import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
describe("plugin scaffold", () => {
it("declares capabilities for its manifest features", () => {
expect(manifest.capabilities).toContain("events.subscribe");
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
});
it("registers data + actions and handles events", async () => {
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
await plugin.definition.setup(harness.ctx);
@@ -656,6 +673,11 @@ pnpm dev:ui # local dev server with hot-reload events
pnpm test
\`\`\`
\`pnpm dev\` rebuilds the worker, manifest, and UI bundles into \`dist/\`.
When this package is installed from a local path, Paperclip watches that rebuilt
output and reloads the plugin worker. Local installs run trusted code from this
folder on your machine.
${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`
: ""}
@@ -663,9 +685,7 @@ ${sdkDependency.startsWith("file:")
## Install Into Paperclip
\`\`\`bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
-H "Content-Type: application/json" \\
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
paperclipai plugin install ${shellQuote(toPosixPath(outputDir))}
\`\`\`
## Build Options
@@ -11,7 +11,8 @@ const manifest: PaperclipPluginManifestV1 = {
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write"
"plugin.state.write",
"ui.dashboardWidget.register"
],
entrypoints: {
worker: "./dist/worker.js",
@@ -4,6 +4,11 @@ import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
describe("plugin scaffold", () => {
it("declares capabilities for its manifest features", () => {
expect(manifest.capabilities).toContain("events.subscribe");
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
});
it("registers data + actions and handles events", async () => {
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
await plugin.definition.setup(harness.ctx);
+17 -3
View File
@@ -34,6 +34,7 @@
* @see PLUGIN_SPEC.md §14 — SDK Surface
*/
import fs from "node:fs";
import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url";
@@ -175,6 +176,21 @@ interface EventRegistration {
/** Default timeout for worker→host RPC calls. */
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
function realpathOrResolvedPath(filePath: string): string {
const resolvedPath = path.resolve(filePath);
try {
return fs.realpathSync.native(resolvedPath);
} catch {
return resolvedPath;
}
}
export function isWorkerEntrypoint(entry: string, moduleUrl: string): boolean {
const thisFile = realpathOrResolvedPath(fileURLToPath(moduleUrl));
const entryPath = realpathOrResolvedPath(entry);
return thisFile === entryPath;
}
// ---------------------------------------------------------------------------
// startWorkerRpcHost
// ---------------------------------------------------------------------------
@@ -223,9 +239,7 @@ export function runWorker(
}
const entry = process.argv[1];
if (typeof entry !== "string") return;
const thisFile = path.resolve(fileURLToPath(moduleUrl));
const entryPath = path.resolve(entry);
if (thisFile === entryPath) {
if (isWorkerEntrypoint(entry, moduleUrl)) {
startWorkerRpcHost({ plugin });
}
}
@@ -0,0 +1,57 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { isWorkerEntrypoint } from "../src/worker-rpc-host.js";
describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const tempRoot of tempRoots.splice(0)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
function createTempRoot(): string {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-sdk-worker-"));
tempRoots.push(tempRoot);
return tempRoot;
}
it("matches an entrypoint reached through a symlinked directory", () => {
const tempRoot = createTempRoot();
const realDir = path.join(tempRoot, "real");
const linkDir = path.join(tempRoot, "link");
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, linkDir, "dir");
const workerPath = path.join(realDir, "worker.js");
fs.writeFileSync(workerPath, "");
expect(
isWorkerEntrypoint(
path.join(linkDir, "worker.js"),
pathToFileURL(workerPath).toString(),
),
).toBe(true);
});
it("does not match a different entrypoint", () => {
const tempRoot = createTempRoot();
const workerPath = path.join(tempRoot, "worker.js");
const otherPath = path.join(tempRoot, "other.js");
fs.writeFileSync(workerPath, "");
fs.writeFileSync(otherPath, "");
expect(
isWorkerEntrypoint(
otherPath,
pathToFileURL(workerPath).toString(),
),
).toBe(false);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});
+1
View File
@@ -14,6 +14,7 @@ const nonServerProjects = [
"@paperclipai/adapter-acpx-local",
"@paperclipai/adapter-codex-local",
"@paperclipai/adapter-opencode-local",
"@paperclipai/plugin-sdk",
"@paperclipai/ui",
"paperclipai",
];
+98 -18
View File
@@ -1,12 +1,28 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const chokidarMock = vi.hoisted(() => ({
watch: vi.fn(),
}));
vi.mock("chokidar", () => ({
default: chokidarMock,
}));
import { createPluginDevWatcher, resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js";
const tempDirs: string[] = [];
beforeEach(() => {
vi.useRealTimers();
chokidarMock.watch.mockReset();
});
afterEach(() => {
vi.useRealTimers();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
@@ -19,25 +35,49 @@ function makeTempPluginDir(): string {
return dir;
}
function writePluginPackage(pluginDir: string): void {
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@acme/example",
paperclipPlugin: {
manifest: "./dist/manifest.js",
worker: "./dist/worker.js",
ui: "./dist/ui",
},
}),
);
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
}
function createLifecycle() {
const emitter = new EventEmitter();
return Object.assign(emitter, {
restartWorker: vi.fn().mockResolvedValue(undefined),
});
}
function installMockFsWatcher() {
const handlers: Record<string, (...args: unknown[]) => void> = {};
const fakeWatcher = {
close: vi.fn(),
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
handlers[event] = listener;
return fakeWatcher;
}),
};
chokidarMock.watch.mockReturnValue(fakeWatcher);
return { fakeWatcher, handlers };
}
describe("resolvePluginWatchTargets", () => {
it("watches package metadata plus concrete declared runtime files", () => {
const pluginDir = makeTempPluginDir();
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@acme/example",
paperclipPlugin: {
manifest: "./dist/manifest.js",
worker: "./dist/worker.js",
ui: "./dist/ui",
},
}),
);
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
writePluginPackage(pluginDir);
const targets = resolvePluginWatchTargets(pluginDir);
@@ -66,3 +106,43 @@ describe("resolvePluginWatchTargets", () => {
]);
});
});
describe("createPluginDevWatcher", () => {
it("starts watching local plugins announced by lifecycle events", async () => {
const pluginDir = makeTempPluginDir();
writePluginPackage(pluginDir);
installMockFsWatcher();
const lifecycle = createLifecycle();
const devWatcher = createPluginDevWatcher(
lifecycle as never,
async (pluginId) => (pluginId === "plugin-1" ? pluginDir : null),
);
lifecycle.emit("plugin.loaded", { pluginId: "plugin-1", pluginKey: "example" });
await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(1));
const [watchedPaths] = chokidarMock.watch.mock.calls[0] ?? [];
expect(watchedPaths).toContain(path.join(pluginDir, "dist", "worker.js"));
devWatcher.close();
});
it("debounces watched file changes and restarts the plugin worker", async () => {
vi.useFakeTimers();
const pluginDir = makeTempPluginDir();
writePluginPackage(pluginDir);
const { handlers } = installMockFsWatcher();
const lifecycle = createLifecycle();
const devWatcher = createPluginDevWatcher(lifecycle as never);
devWatcher.watch("plugin-1", pluginDir);
handlers.all?.("change", path.join(pluginDir, "dist", "worker.js"));
await vi.advanceTimersByTimeAsync(500);
expect(lifecycle.restartWorker).toHaveBeenCalledWith("plugin-1");
devWatcher.close();
});
});
@@ -224,6 +224,46 @@ describe.sequential("plugin install and upgrade authz", () => {
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("resolves plugin keys without probing the UUID id column for core plugin actions", async () => {
const pluginKey = "paperclipqa.hello-plugin";
const plugin = {
id: pluginId,
pluginKey,
version: "1.0.0",
status: "ready",
};
mockRegistry.getById.mockImplementation(() => {
throw new Error("getById should not be called for plugin keys");
});
mockRegistry.getByKey.mockResolvedValue(plugin);
mockLifecycle.unload.mockResolvedValue(plugin);
mockLifecycle.enable.mockResolvedValue(plugin);
mockLifecycle.disable.mockResolvedValue(plugin);
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const inspectRes = await request(app).get(`/api/plugins/${pluginKey}`);
const disableRes = await request(app).post(`/api/plugins/${pluginKey}/disable`).send({});
const enableRes = await request(app).post(`/api/plugins/${pluginKey}/enable`).send({});
const uninstallRes = await request(app).delete(`/api/plugins/${pluginKey}?purge=true`);
expect(inspectRes.status).toBe(200);
expect(disableRes.status).toBe(200);
expect(enableRes.status).toBe(200);
expect(uninstallRes.status).toBe(200);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockRegistry.getByKey).toHaveBeenCalledWith(pluginKey);
expect(mockLifecycle.disable).toHaveBeenCalledWith(pluginId, undefined);
expect(mockLifecycle.enable).toHaveBeenCalledWith(pluginId);
expect(mockLifecycle.unload).toHaveBeenCalledWith(pluginId, true);
}, 20_000);
it("rejects plugin config saves that contain secret refs even for instance admins", async () => {
readyPlugin();
+4 -6
View File
@@ -420,12 +420,10 @@ export async function createApp(
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
const devWatcher = opts.uiMode === "vite-dev"
? createPluginDevWatcher(
lifecycle,
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
)
: null;
const devWatcher = createPluginDevWatcher(
lifecycle,
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
);
void loader.loadAll().then((result) => {
if (!result) return;
for (const loaded of result.results) {
+6 -20
View File
@@ -199,9 +199,9 @@ function listBundledPluginExamples(): AvailablePluginExample[] {
*
* Lookup order:
* - UUID-like IDs: getById first, then getByKey.
* - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById.
* - Other non-UUID IDs: try getById first (test/memory registries may allow this),
* then fallback to getByKey. Any UUID parse error from getById is ignored.
* - All non-UUID values: getByKey only, never getById. The persisted plugin
* ID column is a PostgreSQL UUID, so probing it with keys such as
* "acme.plugin" raises a database cast error before a key lookup can happen.
*
* @param registry - The plugin registry service instance
* @param pluginId - Either a database UUID or plugin key (manifest id)
@@ -212,27 +212,13 @@ async function resolvePlugin(
pluginId: string,
) {
const isUuid = UUID_REGEX.test(pluginId);
const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/");
// Scoped package IDs are valid plugin keys but invalid UUIDs.
// Skip getById() entirely to avoid Postgres uuid parse errors.
if (isScopedPackageKey && !isUuid) {
if (!isUuid) {
return registry.getByKey(pluginId);
}
try {
const byId = await registry.getById(pluginId);
if (byId) return byId;
} catch (error) {
const maybeCode =
typeof error === "object" && error !== null && "code" in error
? (error as { code?: unknown }).code
: undefined;
// Ignore invalid UUID cast errors and continue with key lookup.
if (maybeCode !== "22P02") {
throw error;
}
}
const byId = await registry.getById(pluginId);
if (byId) return byId;
return registry.getByKey(pluginId);
}
+18 -2
View File
@@ -164,6 +164,10 @@ export function createPluginDevWatcher(
const watchers = new Map<string, FSWatcher>();
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const fileExists = fsDeps?.existsSync ?? existsSync;
log.info(
{ resolvesInstalledPlugins: Boolean(resolvePluginPackagePath) },
"plugin-dev-watcher: initialized",
);
function watchPlugin(pluginId: string, packagePath: string): void {
// Don't double-watch
@@ -293,11 +297,23 @@ export function createPluginDevWatcher(
}
async function watchLocalPluginById(pluginId: string): Promise<void> {
if (!resolvePluginPackagePath) return;
if (!resolvePluginPackagePath) {
log.debug(
{ pluginId },
"plugin-dev-watcher: no package path resolver configured, skipping lifecycle event",
);
return;
}
try {
const packagePath = await resolvePluginPackagePath(pluginId);
if (!packagePath) return;
if (!packagePath) {
log.debug(
{ pluginId },
"plugin-dev-watcher: plugin is not a local-path install, skipping watch",
);
return;
}
watchPlugin(pluginId, packagePath);
} catch (err) {
log.warn(
+97 -44
View File
@@ -1,23 +1,34 @@
---
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 and develop external Paperclip plugins with the CLI-first workflow.
Use when scaffolding a new plugin, working on a local plugin against a running
Paperclip instance, or updating plugin authoring docs. Covers `paperclipai
plugin init`, the local install loop via `paperclipai plugin install <path>`,
worker/UI rebuild and reload semantics, and the required success checklist.
---
# Create a Paperclip Plugin
# Create and develop a Paperclip plugin
Use this skill when the task is to create, scaffold, or document a Paperclip plugin.
Use this skill when the task is to create, scaffold, or iterate on a Paperclip plugin against a local Paperclip instance.
## 1. Ground rules
## 1. Default: build the plugin OUTSIDE Paperclip core
Read these first when needed:
Plugins are their own packages. Unless the task **explicitly** asks for a bundled in-repo example, do not add plugin source under `packages/plugins/` in this repo.
- Scaffold the plugin into a directory outside the Paperclip checkout (e.g. `~/dev/paperclip-plugins/<name>`).
- Install it into the running Paperclip instance by local absolute path.
- Edit code in the external package; let Paperclip pick up rebuilt output.
Only edit Paperclip core itself when the user asks to surface a plugin as a bundled example (`server/src/routes/plugins.ts`, in-repo example lists, docs).
## 2. Ground rules
Reference docs when you need detail:
1. `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`
2. `packages/plugins/sdk/README.md`
3. `doc/plugins/PLUGIN_SPEC.md` only for future-looking context
3. `doc/plugins/PLUGIN_SPEC.md` future-looking context only
Current runtime assumptions:
@@ -28,38 +39,67 @@ Current runtime assumptions:
- no host-provided shared plugin UI component kit yet
- `ctx.assets` is not supported in the current runtime
## 2. Preferred workflow
## 3. CLI-first scaffold workflow
Use the scaffold package instead of hand-writing the boilerplate:
Use `paperclipai plugin init`. Do not invoke the scaffold package node entrypoint by hand unless the CLI command is unavailable in the environment.
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js <npm-package-name> --output <target-dir>
paperclipai plugin init @acme/my-plugin --output ~/dev/paperclip-plugins
```
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/`:
Useful flags (all optional):
- `--output <dir>` — parent directory; the command creates `<dir>/<unscoped-name>/`. Defaults to the current directory.
- `--template <default|connector|workspace|environment>` — starter template.
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
- `--display-name <name>`, `--description <text>`, `--author <name>` — manifest metadata.
- `--sdk-path <path>` — snapshot the local SDK from a Paperclip checkout into `.paperclip-sdk/` (useful when developing against an unreleased SDK).
On success the command prints the exact next commands (`cd`, `pnpm install`, `pnpm dev`, `paperclipai plugin install <abs-path>`). Run them in order.
If `paperclipai` is not on PATH in your environment, fall back to:
```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 \
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
--output /absolute/path \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
Recommended target inside this repo:
## 4. Local install + rebuild loop
- `packages/plugins/examples/` for example plugins
- another `packages/plugins/<name>/` folder if it is becoming a real package
In the scaffolded plugin folder:
## 3. After scaffolding
```bash
pnpm install
pnpm dev # esbuild --watch: rebuilds dist/manifest.js, dist/worker.js, dist/ui/
paperclipai plugin install /absolute/path/to/my-plugin
```
Check and adjust:
Notes:
- `src/manifest.ts`
- `src/worker.ts`
- `src/ui/index.tsx`
- `tests/plugin.spec.ts`
- `package.json`
- `paperclipai plugin install` auto-detects local paths (absolute, `./`, `../`, `~`, or an existing relative folder) and forwards `isLocalPath: true` to the server. Pass `--local` to force local mode if the heuristic is ambiguous.
- Paths are resolved to absolute paths before being sent to the server.
- The server watches built outputs (`dist/`) for local-path plugins and restarts the plugin worker on rebuild — you do not need to reinstall after every edit.
- UI hot reload via the SDK dev server (`pnpm dev:ui`, port `4177`) is optional and template-dependent; only mention it if the template wires `devUiUrl` and you verified it works end to end.
- `--version` only applies to npm package installs. Combining it with a local path is an error.
After install, inspect with:
```bash
paperclipai plugin list
paperclipai plugin inspect <plugin-key>
```
## 5. After scaffolding, sanity-check the package
Open and confirm:
- `src/manifest.ts` — declared capabilities and slots
- `src/worker.ts` — worker entry
- `src/ui/index.tsx` — UI entry (if applicable)
- `tests/plugin.spec.ts` — placeholder test
- `package.json``paperclipPlugin` block points at `dist/manifest.js`, `dist/worker.js`, `dist/ui/`
Make sure the plugin:
@@ -68,34 +108,47 @@ Make sure the plugin:
- 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
## 6. Verification (run before declaring success)
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:
From the plugin folder:
```bash
pnpm --filter <plugin-package> typecheck
pnpm --filter <plugin-package> test
pnpm --filter <plugin-package> build
pnpm typecheck
pnpm test
pnpm build
```
If you changed SDK/host/plugin runtime code too, also run broader repo checks as appropriate.
If the plugin is already running under `pnpm dev`, you can keep the watcher up and run `pnpm typecheck` and `pnpm test` in a separate shell.
## 6. Documentation expectations
If you changed Paperclip SDK/host/plugin runtime code in addition to the plugin, also run the relevant Paperclip workspace checks.
## 7. Success checklist (report this back)
When you finish a local plugin task, report:
- **Scaffold path** — absolute path of the created plugin folder.
- **Commands run** — the exact `paperclipai plugin init`, `pnpm install`, `pnpm dev`, `paperclipai plugin install <path>` invocations (and any verification commands).
- **Install status** — output of `paperclipai plugin list` / `plugin inspect` (plugin key, version, status). Note if `status` is anything other than `ready` and include `lastError`.
- **Tests / build result** — `pnpm typecheck`, `pnpm test`, `pnpm build` pass/fail with the failing output if any.
- **Reload limitations** — call out anything that did not hot-reload (e.g. manifest changes required a reinstall, UI dev server was not wired, etc.).
If any item is missing, mark it as such — do not silently skip.
## 8. When NOT to edit Paperclip core
Do not add the plugin under `packages/plugins/` or update bundled-example wiring unless the user explicitly asks for a bundled example. Local-path installs are the supported development model; npm packages are the production deployment path.
If the user does ask for a bundled example, also update:
- `server/src/routes/plugins.ts` example list
- any docs that enumerate in-repo example plugins
## 9. 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
- prefer local-path development + npm-package deployment guidance over repo-local workflows
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
"packages/adapters/gemini-local",
"packages/adapters/opencode-local",
"packages/adapters/pi-local",
"packages/plugins/sdk",
"server",
"ui",
"cli",