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; } /** Subset of `GET /api/health` we surface as install/target diagnostics. */ interface TargetHealth { status?: string; version?: string; deploymentMode?: string; deploymentExposure?: string; } /** Result of probing the Paperclip instance the CLI is about to talk to. */ interface TargetDiagnostics { apiBase: string; reachable: boolean; health?: TargetHealth; error?: string; } // --------------------------------------------------------------------------- // Option types // --------------------------------------------------------------------------- interface PluginListOptions extends BaseClientOptions { status?: string; } interface PluginInstallOptions extends BaseClientOptions { local?: boolean; version?: string; /** When false, skip the pre-install target-host health probe. Defaults true. */ verifyTarget?: boolean; } 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 PluginJsonOptions extends BaseClientOptions { payloadJson?: string; } interface PluginStreamOptions extends BaseClientOptions { durationMs?: string; } interface PluginCompanyOptions extends PluginJsonOptions { companyId?: string; } function requireCompanyId(ctx: { companyId?: string }): string { if (!ctx.companyId) { throw new Error( "Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclipai context set`.", ); } return ctx.companyId; } 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"); } /** * Probe `GET /api/health` on the instance the CLI is configured to talk to so a * developer can confirm *which* Paperclip they are about to install into. This * exists because a local-path plugin can otherwise be silently installed into a * stale control-plane host that does not serve the branch's routes; surfacing * the API URL plus the server version/status catches that mismatch before the * plugin is exercised against the wrong runtime. */ export async function probeTargetDiagnostics( api: { apiBase: string; get(path: string): Promise }, ): Promise { try { const health = await api.get("/api/health"); return { apiBase: api.apiBase, reachable: true, health: health ?? undefined, }; } catch (err) { return { apiBase: api.apiBase, reachable: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Render the target-host diagnostics as human-readable lines. Pure so it can be * unit-tested without a live server. */ export function formatTargetDiagnostics(diag: TargetDiagnostics): string { const lines = [pc.dim(`Target Paperclip: ${pc.cyan(diag.apiBase)}`)]; if (!diag.reachable) { lines.push(pc.yellow(` health: unreachable${diag.error ? ` (${diag.error.split("\n")[0]})` : ""}`)); lines.push( pc.dim( ` Verify the right instance is running, then pass ${pc.cyan("--api-base ")} or set ${pc.cyan("PAPERCLIP_API_URL")} if it lives elsewhere.`, ), ); return lines.join("\n"); } const health = diag.health ?? {}; const detailParts: string[] = []; if (health.status) detailParts.push(`status=${health.status}`); if (health.version) detailParts.push(`version=${health.version}`); if (health.deploymentMode) detailParts.push(`mode=${health.deploymentMode}`); if (health.deploymentExposure) detailParts.push(`exposure=${health.deploymentExposure}`); lines.push( pc.dim(` health: ${detailParts.length > 0 ? detailParts.join(" ") : "ok (no details exposed)"}`), ); return lines.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