From 0f831e09c1e9e8c2f3b8a05694aa67bd08c53600 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Mar 2026 13:58:43 -0700 Subject: [PATCH 1/5] Add plugin cli commands --- cli/src/commands/client/plugin.ts | 365 ++++++++++++++++++++++++++++++ cli/src/index.ts | 2 + 2 files changed, 367 insertions(+) create mode 100644 cli/src/commands/client/plugin.ts diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts new file mode 100644 index 00000000..dd2fb08e --- /dev/null +++ b/cli/src/commands/client/plugin.ts @@ -0,0 +1,365 @@ +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; + 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 plugin = await ctx.api.post("/api/plugins/install", { + packageName: resolvedPackage, + version: opts.version, + isLocalPath: isLocal, + }); + + if (ctx.json) { + printOutput(plugin, { json: true }); + return; + } + + if (!plugin) { + console.log(pc.dim("Install returned no plugin record.")); + return; + } + + console.log(pc.green(`✓ Installed ${pc.bold(plugin.pluginKey)} v${plugin.version} (${plugin.status})`)); + + if (plugin.lastError) { + console.log(pc.red(` Warning: ${plugin.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 (!result) { + console.log(pc.red(`Plugin not found: ${pluginKey}`)); + process.exit(1); + } + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + 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); + } + }), + ); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 19ef69f9..628cd7e7 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; +import { registerPluginCommands } from "./commands/client/plugin.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -136,6 +137,7 @@ registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); registerWorktreeCommands(program); +registerPluginCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); From d0677dcd915471fca41ffa90a5b42138e159bc2e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Mar 2026 14:08:03 -0700 Subject: [PATCH 2/5] Update cli/src/commands/client/plugin.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- cli/src/commands/client/plugin.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index dd2fb08e..c032b988 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -55,6 +55,11 @@ 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); } From 4f8df1804df03557f59bd377063f1f2195032c30 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Mar 2026 14:08:15 -0700 Subject: [PATCH 3/5] Update cli/src/commands/client/plugin.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- cli/src/commands/client/plugin.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index c032b988..bb54f00d 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -303,14 +303,15 @@ export function registerPluginCommands(program: Command): void { `/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); } - - if (ctx.json) { - printOutput(result, { json: true }); - return; } console.log(formatPlugin(result)); From 0afd5d5630f394c22e585ec803498e4c1b1f53d2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Mar 2026 14:08:21 -0700 Subject: [PATCH 4/5] Update cli/src/commands/client/plugin.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- cli/src/commands/client/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index bb54f00d..59733915 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -167,7 +167,7 @@ export function registerPluginCommands(program: Command): void { ); } - const plugin = await ctx.api.post("/api/plugins/install", { + const installedPlugin = await ctx.api.post("/api/plugins/install", { packageName: resolvedPackage, version: opts.version, isLocalPath: isLocal, From e219761d9591c17e92663251ae3eff08856acfdf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Mar 2026 14:15:42 -0700 Subject: [PATCH 5/5] Fix plugin installation output and error handling in registerPluginCommands --- cli/src/commands/client/plugin.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 59733915..9031d696 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -174,19 +174,23 @@ export function registerPluginCommands(program: Command): void { }); if (ctx.json) { - printOutput(plugin, { json: true }); + printOutput(installedPlugin, { json: true }); return; } - if (!plugin) { + if (!installedPlugin) { console.log(pc.dim("Install returned no plugin record.")); return; } - console.log(pc.green(`✓ Installed ${pc.bold(plugin.pluginKey)} v${plugin.version} (${plugin.status})`)); + console.log( + pc.green( + `✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`, + ), + ); - if (plugin.lastError) { - console.log(pc.red(` Warning: ${plugin.lastError}`)); + if (installedPlugin.lastError) { + console.log(pc.red(` Warning: ${installedPlugin.lastError}`)); } } catch (err) { handleCommandError(err); @@ -312,7 +316,6 @@ export function registerPluginCommands(program: Command): void { console.log(pc.red(`Plugin not found: ${pluginKey}`)); process.exit(1); } - } console.log(formatPlugin(result)); if (result.lastError) {