import path from "node:path"; 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, handleCommandError, printOutput, resolveCommandContext, type BaseClientOptions, } from "./common.js"; // --------------------------------------------------------------------------- // Types mirroring server-side shapes // --------------------------------------------------------------------------- interface PluginRecord { id: string; pluginKey: string; packageName: string; version: string; status: string; displayName?: string; lastError?: string | null; installedAt: string; updatedAt: string; } // --------------------------------------------------------------------------- // Option types // --------------------------------------------------------------------------- interface PluginListOptions extends BaseClientOptions { status?: string; } interface PluginInstallOptions extends BaseClientOptions { local?: boolean; 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, cwd = process.cwd()): string { if (!isLocal) return packageArg; if (path.isAbsolute(packageArg)) return packageArg; if (packageArg.startsWith("~")) return expandHomePath(packageArg); return path.resolve(cwd, packageArg); } export function buildPluginInstallRequest( packageArg: string, opts: Pick = {}, 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 { 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 { const statusColor = p.status === "ready" ? pc.green(p.status) : p.status === "error" ? pc.red(p.status) : p.status === "disabled" ? pc.dim(p.status) : pc.yellow(p.status); const parts = [ `key=${pc.bold(p.pluginKey)}`, `status=${statusColor}`, `version=${p.version}`, `id=${pc.dim(p.id)}`, ]; if (p.lastError) { parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`); } 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 // --------------------------------------------------------------------------- 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