diff --git a/cli/package.json b/cli/package.json index 2dffbeeeb6b..53f19e72c31 100644 --- a/cli/package.json +++ b/cli/package.json @@ -47,6 +47,7 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/create-paperclip-plugin": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/cli/src/__tests__/plugin-init.test.ts b/cli/src/__tests__/plugin-init.test.ts new file mode 100644 index 00000000000..dbda1e01268 --- /dev/null +++ b/cli/src/__tests__/plugin-init.test.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir), +})); + +vi.mock("@paperclipai/create-paperclip-plugin", () => ({ + scaffoldPluginProject: mocks.scaffoldPluginProject, +})); + +import { + buildPluginInitNextCommands, + buildPluginInitScaffoldOptions, + registerPluginCommands, +} from "../commands/client/plugin.js"; + +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", + }); + }); +}); diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 9031d696ed4..e756530e82a 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -1,5 +1,9 @@ import path from "node:path"; -import { Command } from "commander"; +import { Command, Option } from "commander"; +import { + scaffoldPluginProject, + type ScaffoldPluginOptions, +} from "@paperclipai/create-paperclip-plugin"; import pc from "picocolors"; import { addCommonClientOptions, @@ -43,6 +47,21 @@ 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 // --------------------------------------------------------------------------- @@ -87,6 +106,63 @@ function formatPlugin(p: PluginRecord): string { return parts.join(" "); } +function packageToDirName(pluginName: string): string { + return pluginName.replace(/^@[^/]+\//, ""); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + +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 +170,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