[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:
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user