375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
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 <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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// plugin install <package-or-path>
|
|
// -------------------------------------------------------------------------
|
|
addCommonClientOptions(
|
|
plugin
|
|
.command("install <package>")
|
|
.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 <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);
|
|
|
|
// 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<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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// plugin uninstall <plugin-key-or-id>
|
|
// -------------------------------------------------------------------------
|
|
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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// plugin enable <plugin-key-or-id>
|
|
// -------------------------------------------------------------------------
|
|
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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// plugin disable <plugin-key-or-id>
|
|
// -------------------------------------------------------------------------
|
|
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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// plugin inspect <plugin-key-or-id>
|
|
// -------------------------------------------------------------------------
|
|
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);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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);
|
|
}
|
|
}),
|
|
);
|
|
}
|