2026-03-13 16:22:34 -05:00
# ! / u s r / b i n / e n v n o d e
2026-03-14 10:40:21 -05:00
import { execFileSync } from "node:child_process" ;
2026-03-13 16:22:34 -05:00
import fs from "node:fs" ;
import path from "node:path" ;
2026-03-14 10:40:21 -05:00
import { fileURLToPath } from "node:url" ;
2026-03-13 16:22:34 -05:00
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" ;
2026-03-14 10:40:21 -05:00
sdkPath? : string ;
2026-03-13 16:22:34 -05:00
}
/** 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 ) ;
}
2026-03-14 10:40:21 -05:00
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 ;
}
2026-03-13 16:22:34 -05:00
/ * *
* 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 ) ;
2026-03-14 10:40:21 -05:00
const localSdkPath = path . resolve ( options . sdkPath ? ? getLocalSdkPackagePath ( ) ) ;
const localSharedPath = getLocalSharedPackagePath ( localSdkPath ) ;
const repoRoot = getRepoRootFromSdkPath ( localSdkPath ) ;
const useWorkspaceSdk = isInsideDir ( outputDir , repoRoot ) ;
2026-03-13 16:22:34 -05:00
fs . mkdirSync ( outputDir , { recursive : true } ) ;
2026-03-14 10:40:21 -05:00
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage ( localSharedPath , outputDir ) ;
const sdkDependency = useWorkspaceSdk
? "workspace:*"
: ` file: ${ toPosixPath ( path . relative ( outputDir , packLocalPackage ( localSdkPath , outputDir ) ) ) } ` ;
2026-03-13 16:22:34 -05:00
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" ,
2026-03-14 10:40:21 -05:00
test : "vitest run --config ./vitest.config.ts" ,
2026-03-13 16:22:34 -05:00
typecheck : "tsc --noEmit"
} ,
paperclipPlugin : {
manifest : "./dist/manifest.js" ,
worker : "./dist/worker.js" ,
ui : "./dist/ui/"
} ,
keywords : [ "paperclip" , "plugin" , category ] ,
author ,
license : "MIT" ,
2026-03-14 10:40:21 -05:00
. . . ( packedSharedTarball
? {
pnpm : {
overrides : {
"@paperclipai/shared" : ` file: ${ toPosixPath ( path . relative ( outputDir , packedSharedTarball ) ) } ` ,
} ,
} ,
}
: { } ) ,
2026-03-13 16:22:34 -05:00
devDependencies : {
2026-03-14 10:40:21 -05:00
. . . ( packedSharedTarball
? {
"@paperclipai/shared" : ` file: ${ toPosixPath ( path . relative ( outputDir , packedSharedTarball ) ) } ` ,
}
: { } ) ,
"@paperclipai/plugin-sdk" : sdkDependency ,
2026-03-13 16:22:34 -05:00
"@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" ,
2026-03-14 10:40:21 -05:00
rootDir : "."
2026-03-13 16:22:34 -05:00
} ,
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 ) ;
` ,
) ;
2026-03-14 10:40:21 -05:00
writeFile (
path . join ( outputDir , "vitest.config.ts" ) ,
` import { defineConfig } from "vitest/config";
export default defineConfig ( {
test : {
include : [ "tests/**/*.spec.ts" ] ,
environment : "node" ,
} ,
} ) ;
` ,
) ;
2026-03-13 16:22:34 -05:00
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" ) ,
2026-03-14 10:40:21 -05:00
` import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
2026-03-13 16:22:34 -05:00
type HealthData = {
status : "ok" | "degraded" | "error" ;
checkedAt : string ;
} ;
export function DashboardWidget ( _props : PluginWidgetProps ) {
const { data , loading , error } = usePluginData < HealthData > ( "health" ) ;
const ping = usePluginAction ( "ping" ) ;
if ( loading ) return < div > Loading plugin health . . . < / div > ;
2026-03-14 10:40:21 -05:00
if ( error ) return < div > Plugin error : { error . message } < / div > ;
2026-03-13 16:22:34 -05:00
return (
< div style = { { display : "grid" , gap : "0.5rem" } } >
2026-03-14 10:40:21 -05:00
< strong > $ { displayName } < / strong >
< div > Health : { data ? . status ? ? "unknown" } < / div >
< div > Checked : { data ? . checkedAt ? ? "never" } < / div >
2026-03-13 16:22:34 -05:00
< button onClick = { ( ) = > void ping ( ) } > Ping Worker < / button >
< / div >
) ;
}
` ,
) ;
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
\ ` \` \`
2026-03-14 10:40:21 -05:00
$ { 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 `
: "" }
2026-03-13 16:22:34 -05:00
# # Install Into Paperclip
\ ` \` \` bash
2026-03-14 10:40:21 -05:00
curl - X POST http : //127.0.0.1:3100/api/plugins/install \\
- H "Content-Type: application/json" \ \
- d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
2026-03-13 16:22:34 -05:00
\ ` \` \`
# # Build Options
- \ ` pnpm build \` uses esbuild presets from \` @paperclipai/plugin-sdk/bundlers \` .
- \ ` pnpm build:rollup \` uses rollup presets from the same SDK.
` ,
) ;
2026-03-14 10:40:21 -05:00
writeFile ( path . join ( outputDir , ".gitignore" ) , "dist\nnode_modules\n.paperclip-sdk\n" ) ;
2026-03-13 16:22:34 -05:00
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
2026-03-14 10:40:21 -05:00
console . error ( "Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]" ) ;
2026-03-13 16:22:34 -05:00
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 ,
2026-03-14 10:40:21 -05:00
sdkPath : parseArg ( "--sdk-path" ) ,
2026-03-13 16:22:34 -05:00
} ) ;
// eslint-disable-next-line no-console
console . log ( ` Created plugin scaffold at ${ out } ` ) ;
}
if ( import . meta . url === ` file:// ${ process . argv [ 1 ] } ` ) {
runCli ( ) ;
}