| import path from "node:path"; |
| import { Command } from "commander"; |
| import pc from "picocolors"; |
| import { |
| addCommonClientOptions, |
| handleCommandError, |
| printOutput, |
| resolveCommandContext, |
| type BaseClientOptions, |
| } from "./common.js"; |
|
|
| |
| |
| |
|
|
| interface PluginRecord { |
| id: string; |
| pluginKey: string; |
| packageName: string; |
| version: string; |
| status: string; |
| displayName?: string; |
| lastError?: string | null; |
| installedAt: string; |
| updatedAt: string; |
| } |
|
|
|
|
| |
| |
| |
|
|
| interface PluginListOptions extends BaseClientOptions { |
| status?: string; |
| } |
|
|
| interface PluginInstallOptions extends BaseClientOptions { |
| local?: boolean; |
| version?: string; |
| } |
|
|
| interface PluginUninstallOptions extends BaseClientOptions { |
| force?: boolean; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| function resolvePackageArg(packageArg: string, isLocal: boolean): string { |
| if (!isLocal) return packageArg; |
| |
| if (path.isAbsolute(packageArg)) return packageArg; |
| |
| 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(" "); |
| } |
|
|
| |
| |
| |
|
|
| export function registerPluginCommands(program: Command): void { |
| const plugin = program.command("plugin").description("Plugin lifecycle management"); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("list") |
| .description("List installed plugins") |
| .option("--status <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<PluginRecord[]>(`/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("install <package>") |
| .description( |
| "Install a plugin from a local path or npm package.\n" + |
| " Examples:\n" + |
| " penclip plugin install ./my-plugin # local path\n" + |
| " penclip plugin install @acme/plugin-linear # npm package\n" + |
| " penclip plugin install @acme/plugin-linear@1.2 # pinned version", |
| ) |
| .option("-l, --local", "Treat <package> as a local filesystem path", false) |
| .option("--version <version>", "Specific npm version to install (npm packages only)") |
| .action(async (packageArg: string, opts: PluginInstallOptions) => { |
| try { |
| const ctx = resolveCommandContext(opts); |
|
|
| |
| 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<PluginRecord>("/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("uninstall <pluginKey>") |
| .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<PluginRecord | null>( |
| `/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("enable <pluginKey>") |
| .description("Enable a disabled or errored plugin") |
| .action(async (pluginKey: string, opts: BaseClientOptions) => { |
| try { |
| const ctx = resolveCommandContext(opts); |
| const result = await ctx.api.post<PluginRecord>( |
| `/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("disable <pluginKey>") |
| .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<PluginRecord>( |
| `/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| addCommonClientOptions( |
| plugin |
| .command("inspect <pluginKey>") |
| .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<PluginRecord>( |
| `/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); |
| } |
| }), |
| ); |
|
|
| |
| |
| |
| 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(`penclip plugin install ${ex.localPath}`)}`, |
| ); |
| } |
| } catch (err) { |
| handleCommandError(err); |
| } |
| }), |
| ); |
| } |
|
|