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