From b947a7d76c331b3ce4069d3be0ade25cc89b1b90 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 12 May 2026 17:38:24 -0500 Subject: [PATCH] [codex] Improve local plugin development workflow (#5821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- cli/src/__tests__/plugin-init.test.ts | 164 ++++++++++++++ cli/src/commands/client/plugin.ts | 210 +++++++++++++++--- cli/tsconfig.json | 2 +- doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md | 136 ++++++++++++ doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 36 +-- .../create-paperclip-plugin/src/index.ts | 30 ++- .../src/manifest.ts | 3 +- .../tests/plugin.spec.ts | 5 + packages/plugins/sdk/src/worker-rpc-host.ts | 20 +- .../plugins/sdk/tests/worker-rpc-host.test.ts | 57 +++++ packages/plugins/sdk/vitest.config.ts | 8 + scripts/run-vitest-stable.mjs | 1 + .../src/__tests__/plugin-dev-watcher.test.ts | 116 ++++++++-- .../src/__tests__/plugin-routes-authz.test.ts | 40 ++++ server/src/app.ts | 10 +- server/src/routes/plugins.ts | 26 +-- server/src/services/plugin-dev-watcher.ts | 20 +- skills/paperclip-create-plugin/SKILL.md | 141 ++++++++---- vitest.config.ts | 1 + 19 files changed, 875 insertions(+), 151 deletions(-) create mode 100644 cli/src/__tests__/plugin-init.test.ts create mode 100644 doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md create mode 100644 packages/plugins/sdk/tests/worker-rpc-host.test.ts create mode 100644 packages/plugins/sdk/vitest.config.ts diff --git a/cli/src/__tests__/plugin-init.test.ts b/cli/src/__tests__/plugin-init.test.ts new file mode 100644 index 00000000..8faeb459 --- /dev/null +++ b/cli/src/__tests__/plugin-init.test.ts @@ -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( + "../../../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, + }); + }); +}); diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 9031d696..933671e3 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -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 = {}, + 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 + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("init ") + .description("Scaffold a local Paperclip plugin project") + .option("--output ", "Directory to create the plugin folder in") + .addOption( + new Option("--template