diff --git a/.github/workflows/assemble-local.yml b/.github/workflows/assemble-local.yml deleted file mode 100644 index 90f6de42..00000000 --- a/.github/workflows/assemble-local.yml +++ /dev/null @@ -1,193 +0,0 @@ -name: Assemble local branch - -# Triggers on every master push (i.e. after syncing upstream) and on demand. -# Builds the `local` branch: master + fork overlay + cherry-picked pending upstream PRs. -# Syncs build-dev.yml to the `dev` branch so every dev push triggers a build. -# -# PR entries support an optional "exclude:BRANCH" suffix to handle cases where -# one PR branch was rebased onto another. The exclude branch's commits are subtracted -# from the cherry-pick range so they aren't double-applied. -# -# When upstream merges a PR, remove its entry from PR_CHERRY_PICK or PR_SQUASH below. - -on: - push: - branches: [master] - workflow_dispatch: - -permissions: - contents: write - actions: write - -jobs: - assemble: - runs-on: runners-farhoodlabs - timeout-minutes: 15 - steps: - - name: Checkout master - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fetch all remotes - run: | - git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true - git fetch --all --quiet - - - name: Assemble local branch - run: | - set -euo pipefail - - # Start local from master (which mirrors upstream) - git checkout -B local origin/master - - # Apply fork overlay: Dockerfile, build workflows, CLAUDE.md - cp .farhoodlabs/Dockerfile Dockerfile - cp .farhoodlabs/CLAUDE.md CLAUDE.md - mkdir -p .github/workflows - cp .farhoodlabs/.github/workflows/build-prod.yml .github/workflows/build-prod.yml - cp .farhoodlabs/.github/workflows/build-dev.yml .github/workflows/build-dev.yml - git add Dockerfile CLAUDE.md .github/workflows/build-prod.yml .github/workflows/build-dev.yml - git commit -m "chore: apply fork overlay from .farhoodlabs" - - # --- PRs to cherry-pick commit-by-commit (clean, no merge commits) --- - # Format: "PR-number branch-name [exclude:base-branch]" - # Use exclude: when a branch was rebased onto another PR branch to avoid double-applying commits. - # Remove an entry here when upstream merges the PR. - PR_CHERRY_PICK=( - "3237 skill-pat-feature" - "3351 skill-scan-refresh exclude:skill-pat-feature" - "4162 fix/far-108-k8s-adapter-reaper-liveness" - ) - - for entry in "${PR_CHERRY_PICK[@]}"; do - # Parse: pr_num, branch, optional exclude branch - pr_num=$(echo "$entry" | awk '{print $1}') - branch=$(echo "$entry" | awk '{print $2}') - exclude_branch=$(echo "$entry" | grep -oP '(?<=exclude:)\S+' || true) - remote_branch="origin/$branch" - exclude_arg="" - if [ -n "$exclude_branch" ]; then - exclude_arg="--not origin/$exclude_branch" - fi - - if ! git rev-parse "$remote_branch" &>/dev/null; then - echo "WARNING: $remote_branch not found, skipping PR #$pr_num" - continue - fi - - # Exclude commits already on origin/master (fork-overlay/CI infra - # that landed on master via the .farhoodlabs/ overlay path). PR - # branches sometimes pull these in via `git merge origin/master`, - # but cherry-picking them onto `local` (which is already master) - # is redundant and produces conflicts on the assemble-local file. - mapfile -t commits < <(git log --no-merges --reverse --format="%H" upstream/master.."$remote_branch" ^origin/master $exclude_arg) - - if [ ${#commits[@]} -eq 0 ]; then - echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping" - continue - fi - - echo "PR #$pr_num ($branch): cherry-picking ${#commits[@]} commit(s)" - for sha in "${commits[@]}"; do - git cherry-pick "$sha" || { - # If the cherry-pick produced an empty result (commit's content - # is already in HEAD via auto-merge), skip it instead of failing. - # State signature: CHERRY_PICK_HEAD set, no unmerged paths, - # nothing staged. - if [ -f .git/CHERRY_PICK_HEAD ] \ - && [ -z "$(git diff --name-only --diff-filter=U)" ] \ - && git diff --staged --quiet; then - echo "PR #$pr_num: $sha became empty after merge, skipping" - git cherry-pick --skip - continue - fi - echo "::error::Cherry-pick conflict at $sha from PR #$pr_num ($branch)" - echo "::error::Resolve the conflict, force-push the branch, then re-run this workflow" - git cherry-pick --abort - exit 1 - } - done - done - - # --- PRs to apply as a single squash (complex history with merge commits) --- - # git merge --squash applies the net final diff of the branch, bypassing - # intra-PR commit ordering issues. CI commits that cancel out are ignored. - # Remove an entry here when upstream merges the PR. - PR_SQUASH=( - "3987 feat/company-portability-complete" - ) - - for entry in "${PR_SQUASH[@]}"; do - pr_num="${entry%% *}" - branch="${entry#* }" - remote_branch="origin/$branch" - - if ! git rev-parse "$remote_branch" &>/dev/null; then - echo "WARNING: $remote_branch not found, skipping PR #$pr_num" - continue - fi - - # Check if the branch has any unique non-merge commits - unique=$(git log --no-merges --oneline upstream/master.."$remote_branch" | wc -l) - if [ "$unique" -eq 0 ]; then - echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping" - continue - fi - - echo "PR #$pr_num ($branch): applying as squash ($unique non-merge commits)" - git merge --squash "$remote_branch" || { - echo "::error::Squash conflict for PR #$pr_num ($branch)" - git merge --abort 2>/dev/null || git reset --hard HEAD - exit 1 - } - # Only commit if there are staged changes - git diff --staged --quiet || git commit -m "feat: apply PR #$pr_num ($branch)" - done - - git push origin local --force - echo "local branch assembled and pushed" - - - name: Trigger prod build - run: | - curl -sS -X POST \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-prod.yml/dispatches \ - -d '{"ref":"local"}' - - - name: Sync build-dev.yml to dev branch - run: | - set -euo pipefail - - if ! git rev-parse origin/dev &>/dev/null; then - echo "dev branch not found on origin, skipping" - exit 0 - fi - - canonical=".farhoodlabs/.github/workflows/build-dev.yml" - target=".github/workflows/build-dev.yml" - - if git show origin/dev:"$target" 2>/dev/null | diff --brief - "$canonical" &>/dev/null; then - echo "build-dev.yml on dev is up to date, skipping" - exit 0 - fi - - echo "Syncing build-dev.yml to dev branch..." - # Save canonical content before switching branches (.farhoodlabs/ only exists on master) - tmp=$(mktemp) - cp "$canonical" "$tmp" - git checkout -B dev-wf-sync origin/dev - mkdir -p "$(dirname "$target")" - cp "$tmp" "$target" - rm "$tmp" - git add "$target" - git commit -m "chore(ci): sync build-dev.yml from .farhoodlabs" - git push origin dev-wf-sync:dev - echo "build-dev.yml synced to dev" 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