#!/usr/bin/env node import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const VALID_TEMPLATES = ["default", "connector", "workspace"] as const; type PluginTemplate = (typeof VALID_TEMPLATES)[number]; const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const); export interface ScaffoldPluginOptions { pluginName: string; outputDir: string; template?: PluginTemplate; displayName?: string; description?: string; author?: string; category?: "connector" | "workspace" | "automation" | "ui"; sdkPath?: string; } /** Validate npm-style plugin package names (scoped or unscoped). */ export function isValidPluginName(name: string): boolean { const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/; const unscopedPattern = /^[a-z0-9._-]+$/; return scopedPattern.test(name) || unscopedPattern.test(name); } /** Convert `@scope/name` to an output directory basename (`name`). */ function packageToDirName(pluginName: string): string { return pluginName.replace(/^@[^/]+\//, ""); } /** Convert an npm package name into a manifest-safe plugin id. */ function packageToManifestId(pluginName: string): string { if (!pluginName.startsWith("@")) { return pluginName; } return pluginName.slice(1).replace("/", "."); } /** Build a human-readable display name from package name tokens. */ function makeDisplayName(pluginName: string): string { const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim(); return raw .split(/\s+/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function writeFile(target: string, content: string) { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content); } function quote(value: string): string { return JSON.stringify(value); } function toPosixPath(value: string): string { return value.split(path.sep).join("/"); } function formatFileDependency(absPath: string): string { return `file:${toPosixPath(path.resolve(absPath))}`; } function getLocalSdkPackagePath(): string { return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk"); } function getRepoRootFromSdkPath(sdkPath: string): string { return path.resolve(sdkPath, "..", "..", ".."); } function getLocalSharedPackagePath(sdkPath: string): string { return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared"); } function isInsideDir(targetPath: string, parentPath: string): boolean { const relative = path.relative(parentPath, targetPath); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function packLocalPackage(packagePath: string, outputDir: string): string { const packageJsonPath = path.join(packagePath, "package.json"); if (!fs.existsSync(packageJsonPath)) { throw new Error(`Package package.json not found at ${packageJsonPath}`); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: string; version?: string; }; const packageName = packageJson.name ?? path.basename(packagePath); const packageVersion = packageJson.version ?? "0.0.0"; const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`; const sdkBundleDir = path.join(outputDir, ".paperclip-sdk"); fs.mkdirSync(sdkBundleDir, { recursive: true }); execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" }); execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" }); const tarballPath = path.join(sdkBundleDir, tarballFileName); if (!fs.existsSync(tarballPath)) { throw new Error(`Packed tarball was not created at ${tarballPath}`); } return tarballPath; } /** * Generate a complete Paperclip plugin starter project. * * Output includes manifest/worker/UI entries, SDK harness tests, bundler presets, * and a local dev server script for hot-reload workflow. */ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string { const template = options.template ?? "default"; if (!VALID_TEMPLATES.includes(template)) { throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`); } if (!isValidPluginName(options.pluginName)) { throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens."); } if (options.category && !VALID_CATEGORIES.has(options.category)) { throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`); } const outputDir = path.resolve(options.outputDir); if (fs.existsSync(outputDir)) { throw new Error(`Directory already exists: ${outputDir}`); } const displayName = options.displayName ?? makeDisplayName(options.pluginName); const description = options.description ?? "A Paperclip plugin"; const author = options.author ?? "Plugin Author"; const category = options.category ?? (template === "workspace" ? "workspace" : "connector"); const manifestId = packageToManifestId(options.pluginName); const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath()); const localSharedPath = getLocalSharedPackagePath(localSdkPath); const repoRoot = getRepoRootFromSdkPath(localSdkPath); const useWorkspaceSdk = isInsideDir(outputDir, repoRoot); fs.mkdirSync(outputDir, { recursive: true }); const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir); const sdkDependency = useWorkspaceSdk ? "workspace:*" : `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`; const packageJson = { name: options.pluginName, version: "0.1.0", type: "module", private: true, description, scripts: { build: "node ./esbuild.config.mjs", "build:rollup": "rollup -c", dev: "node ./esbuild.config.mjs --watch", "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", test: "vitest run --config ./vitest.config.ts", typecheck: "tsc --noEmit" }, paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js", ui: "./dist/ui/" }, keywords: ["paperclip", "plugin", category], author, license: "MIT", ...(packedSharedTarball ? { pnpm: { overrides: { "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, }, }, } : {}), devDependencies: { ...(packedSharedTarball ? { "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, } : {}), "@paperclipai/plugin-sdk": sdkDependency, "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^24.6.0", "@types/react": "^19.0.8", esbuild: "^0.27.3", rollup: "^4.38.0", tslib: "^2.8.1", typescript: "^5.7.3", vitest: "^3.0.5" }, peerDependencies: { react: ">=18" } }; writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); const tsconfig = { compilerOptions: { target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", lib: ["ES2022", "DOM"], jsx: "react-jsx", strict: true, skipLibCheck: true, declaration: true, declarationMap: true, sourceMap: true, outDir: "dist", rootDir: "." }, include: ["src", "tests"], exclude: ["dist", "node_modules"] }; writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); writeFile( path.join(outputDir, "esbuild.config.mjs"), `import esbuild from "esbuild"; import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); const watch = process.argv.includes("--watch"); const workerCtx = await esbuild.context(presets.esbuild.worker); const manifestCtx = await esbuild.context(presets.esbuild.manifest); const uiCtx = await esbuild.context(presets.esbuild.ui); if (watch) { await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); console.log("esbuild watch mode enabled for worker, manifest, and ui"); } else { await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); } `, ); writeFile( path.join(outputDir, "rollup.config.mjs"), `import { nodeResolve } from "@rollup/plugin-node-resolve"; import typescript from "@rollup/plugin-typescript"; import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); function withPlugins(config) { if (!config) return null; return { ...config, plugins: [ nodeResolve({ extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], }), typescript({ tsconfig: "./tsconfig.json", declaration: false, declarationMap: false, }), ], }; } export default [ withPlugins(presets.rollup.manifest), withPlugins(presets.rollup.worker), withPlugins(presets.rollup.ui), ].filter(Boolean); `, ); writeFile( path.join(outputDir, "vitest.config.ts"), `import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["tests/**/*.spec.ts"], environment: "node", }, }); `, ); writeFile( path.join(outputDir, "src", "manifest.ts"), `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; const manifest: PaperclipPluginManifestV1 = { id: ${quote(manifestId)}, apiVersion: 1, version: "0.1.0", displayName: ${quote(displayName)}, description: ${quote(description)}, author: ${quote(author)}, categories: [${quote(category)}], capabilities: [ "events.subscribe", "plugin.state.read", "plugin.state.write" ], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" }, ui: { slots: [ { type: "dashboardWidget", id: "health-widget", displayName: ${quote(`${displayName} Health`)}, exportName: "DashboardWidget" } ] } }; export default manifest; `, ); writeFile( path.join(outputDir, "src", "worker.ts"), `import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; const plugin = definePlugin({ async setup(ctx) { ctx.events.on("issue.created", async (event) => { const issueId = event.entityId ?? "unknown"; await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); ctx.logger.info("Observed issue.created", { issueId }); }); ctx.data.register("health", async () => { return { status: "ok", checkedAt: new Date().toISOString() }; }); ctx.actions.register("ping", async () => { ctx.logger.info("Ping action invoked"); return { pong: true, at: new Date().toISOString() }; }); }, async onHealth() { return { status: "ok", message: "Plugin worker is running" }; } }); export default plugin; runWorker(plugin, import.meta.url); `, ); writeFile( path.join(outputDir, "src", "ui", "index.tsx"), `import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; type HealthData = { status: "ok" | "degraded" | "error"; checkedAt: string; }; export function DashboardWidget(_props: PluginWidgetProps) { const { data, loading, error } = usePluginData("health"); const ping = usePluginAction("ping"); if (loading) return
Loading plugin health...
; if (error) return
Plugin error: {error.message}
; return (
${displayName}
Health: {data?.status ?? "unknown"}
Checked: {data?.checkedAt ?? "never"}
); } `, ); writeFile( path.join(outputDir, "tests", "plugin.spec.ts"), `import { describe, expect, it } from "vitest"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; import manifest from "../src/manifest.js"; import plugin from "../src/worker.js"; describe("plugin scaffold", () => { it("registers data + actions and handles events", async () => { const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); await plugin.definition.setup(harness.ctx); await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); const data = await harness.getData<{ status: string }>("health"); expect(data.status).toBe("ok"); const action = await harness.performAction<{ pong: boolean }>("ping"); expect(action.pong).toBe(true); }); }); `, ); writeFile( path.join(outputDir, "README.md"), `# ${displayName} ${description} ## Development \`\`\`bash pnpm install pnpm dev # watch builds pnpm dev:ui # local dev server with hot-reload events pnpm test \`\`\` ${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` : ""} ## 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}' \`\`\` ## Build Options - \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`. - \`pnpm build:rollup\` uses rollup presets from the same SDK. `, ); writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n"); return outputDir; } function parseArg(name: string): string | undefined { const index = process.argv.indexOf(name); if (index === -1) return undefined; return process.argv[index + 1]; } /** CLI wrapper for `scaffoldPluginProject`. */ function runCli() { const pluginName = process.argv[2]; if (!pluginName) { // eslint-disable-next-line no-console console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ] [--sdk-path ]"); process.exit(1); } const template = (parseArg("--template") ?? "default") as PluginTemplate; const outputRoot = parseArg("--output") ?? process.cwd(); const targetDir = path.resolve(outputRoot, packageToDirName(pluginName)); const out = scaffoldPluginProject({ pluginName, outputDir: targetDir, template, displayName: parseArg("--display-name"), description: parseArg("--description"), author: parseArg("--author"), category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined, sdkPath: parseArg("--sdk-path"), }); // eslint-disable-next-line no-console console.log(`Created plugin scaffold at ${out}`); } if (import.meta.url === `file://${process.argv[1]}`) { runCli(); }