[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
@@ -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"],
},
});