import path from "node:path"; import { Command } from "commander"; 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 PluginUninstallOptions extends BaseClientOptions { force?: boolean; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * 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): string { if (!isLocal) return packageArg; // Already absolute if (path.isAbsolute(packageArg)) return packageArg; // Expand leading ~ to home directory if (packageArg.startsWith("~")) { const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, "")); } return path.resolve(process.cwd(), packageArg); } 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(" "); } // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- export function registerPluginCommands(program: Command): void { const plugin = program.command("plugin").description("Plugin lifecycle management"); // ------------------------------------------------------------------------- // plugin list // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("list") .description("List installed plugins") .option("--status ", "Filter by status (ready, error, disabled, installed, upgrade_pending)") .action(async (opts: PluginListOptions) => { try { const ctx = resolveCommandContext(opts); const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ""; const plugins = await ctx.api.get(`/api/plugins${qs}`); if (ctx.json) { printOutput(plugins, { json: true }); return; } const rows = plugins ?? []; if (rows.length === 0) { console.log(pc.dim("No plugins installed.")); return; } for (const p of rows) { console.log(formatPlugin(p)); } } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin install // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("install ") .description( "Install a plugin from a local path or npm package.\n" + " Examples:\n" + " paperclipai plugin install ./my-plugin # local path\n" + " paperclipai plugin install @acme/plugin-linear # npm package\n" + " paperclipai plugin install @acme/plugin-linear@1.2 # pinned version", ) .option("-l, --local", "Treat as a local filesystem path", false) .option("--version ", "Specific npm version to install (npm packages only)") .action(async (packageArg: string, opts: PluginInstallOptions) => { try { const ctx = resolveCommandContext(opts); // Auto-detect local paths: starts with . or / or ~ or is an absolute path const isLocal = opts.local || packageArg.startsWith("./") || packageArg.startsWith("../") || packageArg.startsWith("/") || packageArg.startsWith("~"); const resolvedPackage = resolvePackageArg(packageArg, isLocal); if (!ctx.json) { console.log( pc.dim( isLocal ? `Installing plugin from local path: ${resolvedPackage}` : `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`, ), ); } const installedPlugin = await ctx.api.post("/api/plugins/install", { packageName: resolvedPackage, version: opts.version, isLocalPath: isLocal, }); if (ctx.json) { printOutput(installedPlugin, { json: true }); return; } if (!installedPlugin) { console.log(pc.dim("Install returned no plugin record.")); return; } console.log( pc.green( `✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`, ), ); if (installedPlugin.lastError) { console.log(pc.red(` Warning: ${installedPlugin.lastError}`)); } } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin uninstall // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("uninstall ") .description( "Uninstall a plugin by its plugin key or database ID.\n" + " Use --force to hard-purge all state and config.", ) .option("--force", "Purge all plugin state and config (hard delete)", false) .action(async (pluginKey: string, opts: PluginUninstallOptions) => { try { const ctx = resolveCommandContext(opts); const purge = opts.force === true; const qs = purge ? "?purge=true" : ""; if (!ctx.json) { console.log( pc.dim( purge ? `Uninstalling and purging plugin: ${pluginKey}` : `Uninstalling plugin: ${pluginKey}`, ), ); } const result = await ctx.api.delete( `/api/plugins/${encodeURIComponent(pluginKey)}${qs}`, ); if (ctx.json) { printOutput(result, { json: true }); return; } console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`)); } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin enable // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("enable ") .description("Enable a disabled or errored plugin") .action(async (pluginKey: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const result = await ctx.api.post( `/api/plugins/${encodeURIComponent(pluginKey)}/enable`, ); if (ctx.json) { printOutput(result, { json: true }); return; } console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin disable // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("disable ") .description("Disable a running plugin without uninstalling it") .action(async (pluginKey: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const result = await ctx.api.post( `/api/plugins/${encodeURIComponent(pluginKey)}/disable`, ); if (ctx.json) { printOutput(result, { json: true }); return; } console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin inspect // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("inspect ") .description("Show full details for an installed plugin") .action(async (pluginKey: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const result = await ctx.api.get( `/api/plugins/${encodeURIComponent(pluginKey)}`, ); if (ctx.json) { printOutput(result, { json: true }); return; } if (!result) { console.log(pc.red(`Plugin not found: ${pluginKey}`)); process.exit(1); } console.log(formatPlugin(result)); if (result.lastError) { console.log(`\n${pc.red("Last error:")}\n${result.lastError}`); } } catch (err) { handleCommandError(err); } }), ); // ------------------------------------------------------------------------- // plugin examples // ------------------------------------------------------------------------- addCommonClientOptions( plugin .command("examples") .description("List bundled example plugins available for local install") .action(async (opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const examples = await ctx.api.get< Array<{ packageName: string; pluginKey: string; displayName: string; description: string; localPath: string; tag: string; }> >("/api/plugins/examples"); if (ctx.json) { printOutput(examples, { json: true }); return; } const rows = examples ?? []; if (rows.length === 0) { console.log(pc.dim("No bundled examples available.")); return; } for (const ex of rows) { console.log( `${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` + ` ${ex.description}\n` + ` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`, ); } } catch (err) { handleCommandError(err); } }), ); }