b947a7d76c
## 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>
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|