forked from farhoodlabs/paperclip
[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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
return path.resolve(process.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 {
|
||||
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
@@ -4,5 +4,5 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const nonServerProjects = [
|
||||
"@paperclipai/adapter-acpx-local",
|
||||
"@paperclipai/adapter-codex-local",
|
||||
"@paperclipai/adapter-opencode-local",
|
||||
"@paperclipai/plugin-sdk",
|
||||
"@paperclipai/ui",
|
||||
"paperclipai",
|
||||
];
|
||||
|
||||
@@ -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,9 +35,7 @@ function makeTempPluginDir(): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("resolvePluginWatchTargets", () => {
|
||||
it("watches package metadata plus concrete declared runtime files", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
function writePluginPackage(pluginDir: string): void {
|
||||
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
@@ -38,6 +52,32 @@ describe("resolvePluginWatchTargets", () => {
|
||||
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();
|
||||
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();
|
||||
|
||||
|
||||
+2
-4
@@ -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(
|
||||
const devWatcher = createPluginDevWatcher(
|
||||
lifecycle,
|
||||
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
||||
)
|
||||
: null;
|
||||
);
|
||||
void loader.loadAll().then((result) => {
|
||||
if (!result) return;
|
||||
for (const loaded of result.results) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
return registry.getByKey(pluginId);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user