diff --git a/.farhoodlabs/Dockerfile b/.farhoodlabs/Dockerfile index 46b63f84..2ce01007 100644 --- a/.farhoodlabs/Dockerfile +++ b/.farhoodlabs/Dockerfile @@ -52,7 +52,7 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, editor tools +# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools # Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq RUN apt-get update \ && apt-get install -y --no-install-recommends openssh-client jq nano vim \ @@ -71,6 +71,8 @@ RUN apt-get update \ && chmod +x /usr/local/bin/fj-ex \ && curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \ && chmod +x /usr/local/bin/fgj \ + && curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \ + && chmod +x /usr/local/bin/tea \ && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ && mkdir -p /paperclip \ && chown node:node /paperclip diff --git a/Dockerfile b/Dockerfile index 95c452c9..e367910e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ +COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ COPY patches/ patches/ RUN pnpm install --frozen-lockfile diff --git a/cli/src/__tests__/plugin-init.test.ts b/cli/src/__tests__/plugin-init.test.ts new file mode 100644 index 00000000..8faeb459 --- /dev/null +++ b/cli/src/__tests__/plugin-init.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir), +})); + +vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => { + const actual = + await vi.importActual( + "../../../packages/plugins/create-paperclip-plugin/src/index.js", + ); + return { + ...actual, + scaffoldPluginProject: mocks.scaffoldPluginProject, + }; +}); + +import { + buildPluginInstallRequest, + buildPluginInitNextCommands, + buildPluginInitScaffoldOptions, + registerPluginCommands, +} from "../commands/client/plugin.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("plugin init", () => { + beforeEach(() => { + mocks.scaffoldPluginProject.mockClear(); + }); + + it("maps package name and flags to scaffolder options", () => { + const cwd = path.resolve("/tmp/paperclip-cli-test"); + const options = buildPluginInitScaffoldOptions( + "@acme/plugin-linear", + { + output: "plugins", + template: "connector", + category: "automation", + displayName: "Linear Bridge", + description: "Syncs Linear issues", + author: "Acme", + sdkPath: "../paperclip/packages/plugins/sdk", + }, + cwd, + ); + + expect(options).toEqual({ + pluginName: "@acme/plugin-linear", + outputDir: path.resolve(cwd, "plugins", "plugin-linear"), + template: "connector", + category: "automation", + displayName: "Linear Bridge", + description: "Syncs Linear issues", + author: "Acme", + sdkPath: "../paperclip/packages/plugins/sdk", + }); + }); + + it("builds exact next commands using the scaffold path", () => { + expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([ + "cd '/tmp/acme plugin'", + "pnpm install", + "pnpm dev", + "paperclipai plugin install '/tmp/acme plugin'", + ]); + }); + + it("registers the CLI wrapper and invokes the existing scaffolder", async () => { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerPluginCommands(program); + + await program.parseAsync( + [ + "plugin", + "init", + "demo-plugin", + "--output", + "/tmp/paperclip-init-output", + "--template", + "workspace", + "--category", + "workspace", + "--display-name", + "Demo Plugin", + "--description", + "Demo description", + "--author", + "Paperclip", + "--sdk-path", + "/repo/packages/plugins/sdk", + ], + { from: "user" }, + ); + + expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1); + expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({ + pluginName: "demo-plugin", + outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"), + template: "workspace", + category: "workspace", + displayName: "Demo Plugin", + description: "Demo description", + author: "Paperclip", + sdkPath: "/repo/packages/plugins/sdk", + }); + }); +}); + +describe("plugin install", () => { + it("resolves an existing relative local path to an absolute local install request", () => { + const cwd = makeTempDir(); + const pluginDir = path.join(cwd, "demo-plugin"); + fs.mkdirSync(pluginDir); + + expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({ + packageName: pluginDir, + version: undefined, + isLocalPath: true, + }); + }); + + it("keeps an absolute local path absolute and marks it as local", () => { + const pluginDir = path.join(makeTempDir(), "demo-plugin"); + fs.mkdirSync(pluginDir); + + expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({ + packageName: pluginDir, + version: undefined, + isLocalPath: true, + }); + }); + + it("preserves npm package installs when no local path exists", () => { + expect( + buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, { + cwd: makeTempDir(), + }), + ).toEqual({ + packageName: "@acme/plugin-linear", + version: "1.2.3", + isLocalPath: false, + }); + }); +}); diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 9031d696..933671e3 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -1,5 +1,11 @@ import path from "node:path"; -import { Command } from "commander"; +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, @@ -39,28 +45,101 @@ interface PluginInstallOptions extends BaseClientOptions { version?: string; } +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 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): string { +function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): 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(/^[\\/]/, "")); + 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 path.resolve(process.cwd(), packageArg); + + 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"); } function formatPlugin(p: PluginRecord): string { @@ -87,6 +166,58 @@ function formatPlugin(p: PluginRecord): string { 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 // --------------------------------------------------------------------------- @@ -94,6 +225,43 @@ function formatPlugin(p: PluginRecord): string { 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