diff --git a/README.md b/README.md index 6d67b50..cc746c1 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,65 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** — tools that make each unit of engineering work easier than the last. -## Claude Code Install +## Philosophy + +**Each unit of engineering work should make subsequent units easier—not harder.** + +Traditional development accumulates technical debt. Every feature adds complexity. The codebase becomes harder to work with over time. + +Compound engineering inverts this. 80% is in planning and review, 20% is in execution: +- Plan thoroughly before writing code +- Review to catch issues and capture learnings +- Codify knowledge so it's reusable +- Keep quality high so future changes are easy + +**Learn more** + +- [Full component reference](plugins/compound-engineering/README.md) - all agents, commands, skills +- [Compound engineering: how Every codes with agents](https://every.to/chain-of-thought/compound-engineering-how-every-codes-with-agents) +- [The story behind compounding engineering](https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it) + +## Workflow + +``` +Brainstorm -> Plan -> Work -> Review -> Compound -> Repeat + ^ + Ideate (optional -- when you need ideas) +``` + +| Command | Purpose | +|---------|---------| +| `/ce:ideate` | Discover high-impact project improvements through divergent ideation and adversarial filtering | +| `/ce:brainstorm` | Explore requirements and approaches before planning | +| `/ce:plan` | Turn feature ideas into detailed implementation plans | +| `/ce:work` | Execute plans with worktrees and task tracking | +| `/ce:review` | Multi-agent code review before merging | +| `/ce:compound` | Document learnings to make future work easier | + +`/ce:brainstorm` is the main entry point -- it refines ideas into a requirements plan through interactive Q&A, and short-circuits automatically when ceremony isn't needed. `/ce:plan` takes either a requirements doc from brainstorming or a detailed idea and distills it into a technical plan that agents (or humans) can work from. + +`/ce:ideate` is used less often but can be a force multiplier -- it proactively surfaces strong improvement ideas based on your codebase, with optional steering from you. + +Each cycle compounds: brainstorms sharpen plans, plans inform future plans, reviews catch more issues, patterns get documented. + +--- + +## Install + +### Claude Code ```bash /plugin marketplace add EveryInc/compound-engineering-plugin /plugin install compound-engineering ``` -## Cursor Install +### Cursor ```text /add-plugin compound-engineering ``` -## OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro, Windsurf, OpenClaw & Qwen (experimental) Install +### OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro, Windsurf, OpenClaw & Qwen (experimental) This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, Kiro CLI, Windsurf, OpenClaw, and Qwen Code. @@ -60,11 +105,95 @@ bunx @every-env/compound-plugin install compound-engineering --to qwen bunx @every-env/compound-plugin install compound-engineering --to all ``` -### Local Development +
+Output format details per target + +| Target | Output path | Notes | +|--------|------------|-------| +| `opencode` | `~/.config/opencode/` | Commands as `.md` files; `opencode.json` MCP config deep-merged; backups made before overwriting | +| `codex` | `~/.codex/prompts` + `~/.codex/skills` | Claude commands become prompt + skill pairs; canonical `ce:*` workflow skills also get prompt wrappers; deprecated `workflows:*` aliases are omitted | +| `droid` | `~/.factory/` | Tool names mapped (`Bash`->`Execute`, `Write`->`Create`); namespace prefixes stripped | +| `pi` | `~/.pi/agent/` | Prompts, skills, extensions, and `mcporter.json` for MCPorter interoperability | +| `gemini` | `.gemini/` | Skills from agents; commands as `.toml`; namespaced commands become directories (`workflows:plan` -> `commands/workflows/plan.toml`) | +| `copilot` | `.github/` | Agents as `.agent.md` with Copilot frontmatter; MCP env vars prefixed with `COPILOT_MCP_` | +| `kiro` | `.kiro/` | Agents as JSON configs + prompt `.md` files; only stdio MCP servers supported | +| `openclaw` | `~/.openclaw/extensions//` | Entry-point TypeScript skill file; `openclaw-extension.json` for MCP servers | +| `windsurf` | `~/.codeium/windsurf/` (global) or `.windsurf/` (workspace) | Agents become skills; commands become flat workflows; `mcp_config.json` merged | +| `qwen` | `~/.qwen/extensions//` | Agents as `.yaml`; env vars with placeholders extracted as settings; colon separator for nested commands | + +All provider targets are experimental and may change as the formats evolve. + +
+ +--- + +## Installing from a Branch + +When working with worktrees or testing someone else's branch, `./plugins/compound-engineering` points to whatever branch your main checkout is on -- not the branch you want. Use `--branch` to install from a pushed branch without switching checkouts. + +> **Unpushed local branches**: If the branch exists only in a local worktree and hasn't been pushed, point `--plugin-dir` directly at the worktree path instead (e.g. `claude --plugin-dir /path/to/worktree/plugins/compound-engineering`). + +**Claude Code** -- use `plugin-path` to clone the branch to a stable cache directory: + +```bash +bunx @every-env/compound-plugin plugin-path compound-engineering --branch feat/new-agents +# Output: +# claude --plugin-dir ~/.cache/compound-engineering/branches/compound-engineering-feat~new-agents/plugins/compound-engineering +``` + +The cache path is deterministic (same branch always maps to the same directory). Re-running updates the checkout to the latest commit on that branch. + +**Codex, OpenCode, and other targets** -- pass `--branch` to `install`: + +```bash +# install from a specific branch +bunx @every-env/compound-plugin install compound-engineering --to codex --branch feat/new-agents + +# works with any target +bunx @every-env/compound-plugin install compound-engineering --to opencode --branch feat/new-agents + +# combine with --also for multiple targets +bunx @every-env/compound-plugin install compound-engineering --to codex --also opencode --branch feat/new-agents +``` + +Both features use the `COMPOUND_PLUGIN_GITHUB_SOURCE` env var to resolve the repository, defaulting to `https://github.com/EveryInc/compound-engineering-plugin`. + +**Shell aliases** -- `plugin-path` prints just the path to stdout (progress goes to stderr), so it composes with `$()`: + +```bash +# add to ~/.zshrc or ~/.bashrc + +# Launch Claude Code with a specific plugin branch (extra args forwarded to claude) +claude-ce-branch() { + claude --plugin-dir "$(bunx @every-env/compound-plugin plugin-path compound-engineering --branch "$1")" "${@:2}" +} + +# Install a branch to Codex +codex-ce-branch() { + bunx @every-env/compound-plugin install compound-engineering --to codex --branch "$1" +} +``` + +Usage: + +```bash +# Test someone's branch with Claude Code +claude-ce-branch feat/new-agents + +# Pass extra flags through to claude +claude-ce-branch feat/new-agents --verbose + +# Install a branch for Codex +codex-ce-branch feat/new-agents +``` + +--- + +## Local Development When developing and testing local changes to the plugin: -**Claude Code** — add a shell alias so your local copy loads alongside your normal plugins: +**Claude Code** -- add a shell alias so your local copy loads alongside your normal plugins: ```bash # add to ~/.zshrc or ~/.bashrc @@ -79,37 +208,19 @@ echo "alias claude-dev-ce='claude --plugin-dir ~/code/compound-engineering-plugi Then run `claude-dev-ce` instead of `claude` to test your changes. Your production install stays untouched. -**Codex** — point the install command at your local path: +**Codex** -- point the install command at your local path: ```bash bun run src/index.ts install ./plugins/compound-engineering --to codex ``` -**Other targets** — same pattern, swap the target: +**Other targets** -- same pattern, swap the target: ```bash bun run src/index.ts install ./plugins/compound-engineering --to opencode ``` -
-Output format details per target - -| Target | Output path | Notes | -|--------|------------|-------| -| `opencode` | `~/.config/opencode/` | Commands as `.md` files; `opencode.json` MCP config deep-merged; backups made before overwriting | -| `codex` | `~/.codex/prompts` + `~/.codex/skills` | Claude commands become prompt + skill pairs; canonical `ce:*` workflow skills also get prompt wrappers; deprecated `workflows:*` aliases are omitted | -| `droid` | `~/.factory/` | Tool names mapped (`Bash`→`Execute`, `Write`→`Create`); namespace prefixes stripped | -| `pi` | `~/.pi/agent/` | Prompts, skills, extensions, and `mcporter.json` for MCPorter interoperability | -| `gemini` | `.gemini/` | Skills from agents; commands as `.toml`; namespaced commands become directories (`workflows:plan` → `commands/workflows/plan.toml`) | -| `copilot` | `.github/` | Agents as `.agent.md` with Copilot frontmatter; MCP env vars prefixed with `COPILOT_MCP_` | -| `kiro` | `.kiro/` | Agents as JSON configs + prompt `.md` files; only stdio MCP servers supported | -| `openclaw` | `~/.openclaw/extensions//` | Entry-point TypeScript skill file; `openclaw-extension.json` for MCP servers | -| `windsurf` | `~/.codeium/windsurf/` (global) or `.windsurf/` (workspace) | Agents become skills; commands become flat workflows; `mcp_config.json` merged | -| `qwen` | `~/.qwen/extensions//` | Agents as `.yaml`; env vars with placeholders extracted as settings; colon separator for nested commands | - -All provider targets are experimental and may change as the formats evolve. - -
+--- ## Sync Personal Config @@ -180,41 +291,3 @@ Notes: - Droid, Windsurf, Kiro, and Qwen sync merge MCP servers into the provider's documented user config. - OpenClaw currently syncs skills only. Personal command sync is skipped because this repo does not yet have a documented user-level OpenClaw command surface, and MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract. -## Workflow - -``` -Brainstorm → Plan → Work → Review → Compound → Repeat - ↑ - Ideate (optional — when you need ideas) -``` - -| Command | Purpose | -|---------|---------| -| `/ce:ideate` | Discover high-impact project improvements through divergent ideation and adversarial filtering | -| `/ce:brainstorm` | Explore requirements and approaches before planning | -| `/ce:plan` | Turn feature ideas into detailed implementation plans | -| `/ce:work` | Execute plans with worktrees and task tracking | -| `/ce:review` | Multi-agent code review before merging | -| `/ce:compound` | Document learnings to make future work easier | - -The `/ce:ideate` skill proactively surfaces strong improvement ideas, and `/ce:brainstorm` then clarifies the selected one before committing to a plan. - -Each cycle compounds: brainstorms sharpen plans, plans inform future plans, reviews catch more issues, patterns get documented. - -## Philosophy - -**Each unit of engineering work should make subsequent units easier—not harder.** - -Traditional development accumulates technical debt. Every feature adds complexity. The codebase becomes harder to work with over time. - -Compound engineering inverts this. 80% is in planning and review, 20% is in execution: -- Plan thoroughly before writing code -- Review to catch issues and capture learnings -- Codify knowledge so it's reusable -- Keep quality high so future changes are easy - -## Learn More - -- [Full component reference](plugins/compound-engineering/README.md) - all agents, commands, skills -- [Compound engineering: how Every codes with agents](https://every.to/chain-of-thought/compound-engineering-how-every-codes-with-agents) -- [The story behind compounding engineering](https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it) diff --git a/docs/solutions/developer-experience/branch-based-plugin-install-and-testing-2026-03-26.md b/docs/solutions/developer-experience/branch-based-plugin-install-and-testing-2026-03-26.md new file mode 100644 index 0000000..1f6d2b1 --- /dev/null +++ b/docs/solutions/developer-experience/branch-based-plugin-install-and-testing-2026-03-26.md @@ -0,0 +1,130 @@ +--- +title: "Branch-based plugin install and testing for Claude Code plugins" +date: 2026-03-26 +problem_type: developer_experience +category: developer-experience +component: development_workflow +root_cause: missing_workflow_step +resolution_type: workflow_improvement +severity: medium +tags: + - cli + - plugin-install + - branch-testing + - developer-experience + - git-clone + - plugin-path +symptoms: + - "No way to install or test a Claude Code plugin from a specific git branch" + - "install command always cloned the default branch from GitHub" + - "claude --plugin-dir only accepts a local filesystem path with no branch support" + - "Developers had to manually checkout branches to test others' plugin changes" +root_cause_detail: "The CLI lacked any mechanism to target a specific git branch when installing or testing plugins. Claude Code's --plugin-dir flag only accepts local paths, and the install command had no --branch option." +solution_summary: "Added a new plugin-path subcommand that clones a specific branch to a deterministic cache path (~/.cache/compound-engineering/branches/) and outputs it for use with claude --plugin-dir. Also added a --branch flag to the install command for non-Claude targets." +key_insight: "Worktree-based development means multiple branches are active simultaneously and the repo root checkout can't serve as a reliable plugin source. A deterministic cache path based on the sanitized branch name enables branch-specific plugin testing without disrupting any checkout, and re-runs update in place via git fetch + reset --hard." +files_changed: + - src/commands/plugin-path.ts + - src/commands/install.ts + - src/index.ts + - tests/plugin-path.test.ts + - tests/cli.test.ts +verification_steps: + - "Run bun test to confirm all tests pass including 5 new plugin-path tests and 1 new CLI test" + - "Test plugin-path subcommand outputs correct deterministic cache path for a given branch" + - "Test install --branch flag clones from the specified branch for non-Claude targets" + - "Verify re-running plugin-path on same branch updates via fetch+reset rather than re-cloning" +related_docs: + - docs/solutions/adding-converter-target-providers.md + - docs/solutions/plugin-versioning-requirements.md +--- + +## Problem + +The compound-engineering plugin CLI's `install` command always cloned the default branch from GitHub, and Claude Code's `--plugin-dir` flag only accepts local filesystem paths. Developers who wanted to test a plugin from a specific git branch had to manually check out that branch in their local repo, disrupting their working tree. + +This is especially painful in worktree-based workflows where `./plugins/compound-engineering` always points to whatever branch the main checkout is on. Two concrete scenarios: + +- **Cross-repo**: You're working in a different project and want to use a CE branch as your plugin. Without this, you'd have to switch the CE repo's checkout — which is likely WIP on something else. +- **Same-repo**: You're working on CE itself — `feat/feature-2` in your main checkout, `feat/feature-1` in a worktree. You want to test feature-1's plugin while continuing to develop feature-2. The main checkout can't serve both purposes. + +Note: the `--branch` flag works with pushed branches (those available on the remote). For unpushed local worktree branches, developers can point `--plugin-dir` directly at the worktree path (e.g., `claude --plugin-dir /path/to/worktree/plugins/compound-engineering`). + +--- + +## Symptoms + +- Running `bunx compound-engineering install ` always fetched the default branch regardless of what branch contained the changes under review. +- `claude --plugin-dir` required a local path, so there was no way to point it at a remote branch without a manual `git clone` or `git checkout`. +- Developers testing PR branches had to stash or commit their local work, switch branches, test, then switch back -- a disruptive and error-prone workflow. +- In worktree-based workflows, `./plugins/compound-engineering` in the repo root always points to the main checkout's branch, not the worktree branch being developed. Developers working on multiple branches simultaneously had no ergonomic way to install from a specific worktree's branch. +- No scripting path existed to spin up a branch-specific plugin directory for automated testing. + +--- + +## What Didn't Work + +- **Using `/tmp/` for cloned branches** was rejected because temporary directories are cleared on reboot, forcing a full re-clone every session and losing the fast-update path. +- **Random temp directory names** (e.g., `mktemp -d`) were rejected because they cause directory proliferation and make it impossible to re-run the same command and update in place. +- **Extending `claude --plugin-dir` itself** was not an option -- that flag is owned by Claude Code and only accepts local filesystem paths; the solution had to live in the plugin CLI layer. +- **Symlinking the bundled plugin** would not help because the bundled copy is always pinned to the installed CLI version, not an arbitrary remote branch. +- **Naive branch sanitization** (`replace(/[^a-zA-Z0-9._-]/g, "-")`) collapsed distinct branches to the same cache path (e.g., `feat/foo-bar` and `feat-foo/bar` both became `feat-foo-bar`). An escape-then-replace scheme (`~` → `~~`, `/` → `~`) was attempted next but was still not injective — `feat~~foo` and `feat~//foo` both produced `feat~~~~foo`. The correct insight was that `~` is illegal in git branch names (`git-check-ref-format` reserves it for reflog notation), so a simple `/` → `~` replacement is injective without any escape step. + +--- + +## Solution + +Two complementary features were added: + +### 1. New `plugin-path` command (for Claude Code) + +Clones a branch to a deterministic cache directory and prints the path for use with `claude --plugin-dir`. + +```bash +bun run src/index.ts plugin-path compound-engineering --branch feat/new-agents +# Output: claude --plugin-dir ~/.cache/compound-engineering/branches/compound-engineering-feat~new-agents/plugins/compound-engineering +``` + +Key implementation details in `src/commands/plugin-path.ts`: + +- Cache path: `~/.cache/compound-engineering/branches/-/` +- Branch sanitization: `/` → `~`, then strip remaining non-`[a-zA-Z0-9._~-]` chars. This is injective because `~` is illegal in git branch names (`git-check-ref-format` reserves it for reflog notation), so no valid branch input contains `~` and the mapping is 1:1. +- First run: `git clone --depth 1 --branch ` +- Re-run: `git fetch origin ` + `git reset --hard origin/` + +### 2. `--branch` flag on `install` command (for Codex, OpenCode, etc.) + +Threads a branch name through the full resolution chain so `install` clones from the specified branch instead of the default. + +```bash +bun run src/index.ts install compound-engineering --to codex --branch feat/new-agents +``` + +Changes in `src/commands/install.ts`: + +- When `--branch` is provided, skips bundled plugin lookup (user explicitly wants a remote version) +- Threaded through `resolvePluginPath` -> `resolveGitHubPluginPath` -> `cloneGitHubRepo` +- `cloneGitHubRepo` conditionally adds `--branch ` to `git clone --depth 1` + +### Key difference between the two + +`plugin-path` caches the checkout in `~/.cache/` for reuse across sessions. `install --branch` uses an ephemeral temp directory that's cleaned up after the install completes -- it only needs the clone long enough to read and convert the plugin. + +--- + +## Why This Works + +The root issue was a missing indirection layer: the CLI assumed "install" always means "use the default branch," and Claude Code assumes "plugin directory" always means "a path that already exists locally." The solution bridges that gap by: + +- **Deterministic cache paths** mean the same branch always maps to the same directory. No proliferation, no ambiguity. +- **Fetch + hard reset on re-run** keeps the cached checkout current without requiring a full re-clone, making iteration fast. +- **`~/.cache/`** follows XDG conventions, persists across reboots, and is understood by users and tooling as a safe-to-delete cache layer. +- **The `COMPOUND_PLUGIN_GITHUB_SOURCE` env var** works with both features, allowing tests to use local git repos and avoiding network dependency. + +--- + +## Prevention + +- **Test coverage**: `tests/plugin-path.test.ts` (6 tests: clone-to-cache, slash sanitization, update-on-rerun, slash-placement collision resistance, nonexistent branch error, nonexistent plugin error) and `tests/cli.test.ts` (1 test: install --branch clones specific branch). All tests use local git repos via `COMPOUND_PLUGIN_GITHUB_SOURCE`. +- **Cache directory convention**: Any future features that need ephemeral or semi-persistent clones should use `~/.cache/compound-engineering//` with deterministic, sanitized subdirectory names. Avoid `/tmp/` for anything that benefits from surviving a reboot. +- **Branch sanitization**: Always sanitize branch names before using them in filesystem paths. Using `~` as the slash replacement is injective because `~` is illegal in git branch names (`git-check-ref-format`). A naive `replace(/[^a-zA-Z0-9._-]/g, "-")` is insufficient because it collapses branches like `feat/foo-bar` and `feat-foo/bar` into the same path. +- **Resolution chain threading**: When adding new resolution strategies to the CLI, thread optional parameters through the full `resolvePluginPath -> resolveGitHubPluginPath -> cloneGitHubRepo` chain rather than branching at the top level. This keeps the resolution logic composable. diff --git a/src/commands/install.ts b/src/commands/install.ts index 4978037..89ba5dc 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -78,6 +78,10 @@ export default defineCommand({ default: true, description: "Infer agent temperature from name/description", }, + branch: { + type: "string", + description: "Git branch to clone from (e.g. feat/new-agents)", + }, }, async run({ args }) { const targetName = String(args.to) @@ -87,7 +91,8 @@ export default defineCommand({ throw new Error(`Unknown permissions mode: ${permissions}`) } - const resolvedPlugin = await resolvePluginPath(String(args.plugin)) + const branch = args.branch ? String(args.branch) : undefined + const resolvedPlugin = await resolvePluginPath(String(args.plugin), branch) try { const plugin = await loadClaudePlugin(resolvedPlugin.path) @@ -225,7 +230,7 @@ type ResolvedPluginPath = { cleanup?: () => Promise } -async function resolvePluginPath(input: string): Promise { +async function resolvePluginPath(input: string, branch?: string): Promise { // Only treat as a local path if it explicitly looks like one if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) { const expanded = expandHome(input) @@ -234,13 +239,16 @@ async function resolvePluginPath(input: string): Promise { throw new Error(`Local plugin path not found: ${directPath}`) } - const bundledPluginPath = await resolveBundledPluginPath(input) - if (bundledPluginPath) { - return { path: bundledPluginPath } + // Skip bundled plugins when a branch is specified — the user wants a specific remote version + if (!branch) { + const bundledPluginPath = await resolveBundledPluginPath(input) + if (bundledPluginPath) { + return { path: bundledPluginPath } + } } - // Otherwise, fetch the latest from GitHub - return await resolveGitHubPluginPath(input) + // Otherwise, fetch from GitHub (optionally from a specific branch) + return await resolveGitHubPluginPath(input, branch) } function parseExtraTargets(value: unknown): string[] { @@ -271,11 +279,11 @@ async function resolveBundledPluginPath(pluginName: string): Promise { +async function resolveGitHubPluginPath(pluginName: string, branch?: string): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-")) const source = resolveGitHubSource() try { - await cloneGitHubRepo(source, tempRoot) + await cloneGitHubRepo(source, tempRoot, branch) } catch (error) { await fs.rm(tempRoot, { recursive: true, force: true }) throw error @@ -301,8 +309,11 @@ function resolveGitHubSource(): string { return "https://github.com/EveryInc/compound-engineering-plugin" } -async function cloneGitHubRepo(source: string, destination: string): Promise { - const proc = Bun.spawn(["git", "clone", "--depth", "1", source, destination], { +async function cloneGitHubRepo(source: string, destination: string, branch?: string): Promise { + const args = ["git", "clone", "--depth", "1"] + if (branch) args.push("--branch", branch) + args.push(source, destination) + const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", }) diff --git a/src/commands/plugin-path.ts b/src/commands/plugin-path.ts new file mode 100644 index 0000000..d1d0b73 --- /dev/null +++ b/src/commands/plugin-path.ts @@ -0,0 +1,107 @@ +import { defineCommand } from "citty" +import { promises as fs } from "fs" +import os from "os" +import path from "path" + +export default defineCommand({ + meta: { + name: "plugin-path", + description: "Checkout a plugin branch to a stable local path for use with claude --plugin-dir", + }, + args: { + plugin: { + type: "positional", + required: true, + description: "Plugin name (e.g. compound-engineering)", + }, + branch: { + type: "string", + required: true, + description: "Branch name (local or remote, e.g. feat/new-agents)", + }, + }, + async run({ args }) { + const pluginName = String(args.plugin) + const branch = String(args.branch) + + // Reversible encoding: / -> ~ (safe because ~ is illegal in git branch names per + // git-check-ref-format), then percent-encode any remaining unsafe characters. + // This is injective — every distinct branch name maps to a distinct cache key. + const sanitized = branch + .replace(/\//g, "~") + .replace(/[^a-zA-Z0-9._~-]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, "0")}`) + const dirName = `${pluginName}-${sanitized}` + const cacheRoot = path.join(os.homedir(), ".cache", "compound-engineering", "branches") + await fs.mkdir(cacheRoot, { recursive: true }) + const targetDir = path.join(cacheRoot, dirName) + const source = resolveGitHubSource() + + if (await dirExists(targetDir)) { + console.error(`Updating existing checkout at ${targetDir}`) + await fetchAndCheckout(targetDir, branch) + } else { + console.error(`Cloning ${branch} to ${targetDir}`) + await cloneBranch(source, targetDir, branch) + } + + const pluginPath = path.join(targetDir, "plugins", pluginName) + if (!(await dirExists(pluginPath))) { + throw new Error(`Plugin directory not found: ${pluginPath}`) + } + + // Plugin path goes to stdout (for scripting); usage hint goes to stderr + console.error(`\nReady. Use with:\n claude --plugin-dir ${pluginPath}\n`) + console.log(pluginPath) + }, +}) + +async function dirExists(p: string): Promise { + try { + const stat = await fs.stat(p) + return stat.isDirectory() + } catch { + return false + } +} + +async function cloneBranch(source: string, destination: string, branch: string): Promise { + const proc = Bun.spawn(["git", "clone", "--depth", "1", "--branch", branch, source, destination], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`Failed to clone branch '${branch}' from ${source}. ${stderr.trim()}`) + } +} + +async function fetchAndCheckout(repoDir: string, branch: string): Promise { + const fetch = Bun.spawn(["git", "fetch", "origin", branch], { + cwd: repoDir, + stdout: "pipe", + stderr: "pipe", + }) + const fetchExit = await fetch.exited + const fetchErr = await new Response(fetch.stderr).text() + if (fetchExit !== 0) { + throw new Error(`Failed to fetch branch '${branch}'. ${fetchErr.trim()}`) + } + + const reset = Bun.spawn(["git", "reset", "--hard", `origin/${branch}`], { + cwd: repoDir, + stdout: "pipe", + stderr: "pipe", + }) + const resetExit = await reset.exited + const resetErr = await new Response(reset.stderr).text() + if (resetExit !== 0) { + throw new Error(`Failed to reset to origin/${branch}. ${resetErr.trim()}`) + } +} + +function resolveGitHubSource(): string { + const override = process.env.COMPOUND_PLUGIN_GITHUB_SOURCE + if (override && override.trim()) return override.trim() + return "https://github.com/EveryInc/compound-engineering-plugin" +} diff --git a/src/index.ts b/src/index.ts index 2e46e29..481b878 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import packageJson from "../package.json" import convert from "./commands/convert" import install from "./commands/install" import listCommand from "./commands/list" +import pluginPath from "./commands/plugin-path" import sync from "./commands/sync" const main = defineCommand({ @@ -16,6 +17,7 @@ const main = defineCommand({ convert: () => convert, install: () => install, list: () => listCommand, + "plugin-path": () => pluginPath, sync: () => sync, }, }) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index af9f6c3..9299db1 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -282,6 +282,68 @@ describe("CLI", () => { expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) }) + test("install --branch clones a specific branch for non-Claude targets", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-branch-install-")) + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-branch-repo-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering") + + await fs.mkdir(path.dirname(pluginRoot), { recursive: true }) + await fs.cp(fixtureRoot, pluginRoot, { recursive: true }) + + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + } + + await runGit(["init", "-b", "main"], repoRoot, gitEnv) + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "initial"], repoRoot, gitEnv) + await runGit(["checkout", "-b", "feat/test-branch"], repoRoot, gitEnv) + await fs.writeFile(path.join(pluginRoot, "BRANCH_MARKER.txt"), "from-branch") + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "branch commit"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + + const projectRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "install", + "compound-engineering", + "--to", + "opencode", + "--output", + tempRoot, + "--branch", + "feat/test-branch", + ], { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: tempRoot, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) + }) + test("convert writes OpenCode output", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-")) const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") diff --git a/tests/plugin-path.test.ts b/tests/plugin-path.test.ts new file mode 100644 index 0000000..4ede451 --- /dev/null +++ b/tests/plugin-path.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function runGit(args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: env ?? process.env, + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}).\nstderr: ${stderr}`) + } +} + +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", +} + +const projectRoot = path.join(import.meta.dir, "..") +const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + +async function createTestRepo(): Promise { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-repo-")) + const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering") + await fs.mkdir(path.dirname(pluginRoot), { recursive: true }) + await fs.cp(fixtureRoot, pluginRoot, { recursive: true }) + + await runGit(["init", "-b", "main"], repoRoot, gitEnv) + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "initial"], repoRoot, gitEnv) + return repoRoot +} + +describe("plugin-path", () => { + test("clones a branch to a stable cache path", async () => { + const repoRoot = await createTestRepo() + await runGit(["checkout", "-b", "feat/test-branch"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-home-")) + + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "compound-engineering", + "--branch", + "feat/test-branch", + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + const cacheDir = path.join(tempHome, ".cache", "compound-engineering", "branches", "compound-engineering-feat~test-branch") + const pluginDir = path.join(cacheDir, "plugins", "compound-engineering") + + expect(stderr).toContain("claude --plugin-dir") + expect(stdout.trim()).toBe(pluginDir) + expect(await exists(path.join(pluginDir, ".claude-plugin", "plugin.json"))).toBe(true) + }) + + test("sanitizes branch names with slashes into stable directory names", async () => { + const repoRoot = await createTestRepo() + await runGit(["checkout", "-b", "feat/deep/nested/branch"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-sanitize-")) + + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "compound-engineering", + "--branch", + "feat/deep/nested/branch", + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("compound-engineering-feat~deep~nested~branch") + expect(stderr).toContain("claude --plugin-dir") + }) + + test("updates existing checkout on re-run", async () => { + const repoRoot = await createTestRepo() + await runGit(["checkout", "-b", "feat/update-test"], repoRoot, gitEnv) + + // Add a marker file on the branch + const markerPath = path.join(repoRoot, "plugins", "compound-engineering", "MARKER.txt") + await fs.writeFile(markerPath, "v1") + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "add marker v1"], repoRoot, gitEnv) + + await runGit(["checkout", "main"], repoRoot, gitEnv) + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-update-")) + const cacheDir = path.join(tempHome, ".cache", "compound-engineering", "branches", "compound-engineering-feat~update-test") + + const runPluginPath = async () => { + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "compound-engineering", + "--branch", + "feat/update-test", + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + return { stdout, stderr } + } + + // First run: clone + const first = await runPluginPath() + expect(first.stderr).toContain("Cloning") + const cachedMarker = path.join(cacheDir, "plugins", "compound-engineering", "MARKER.txt") + expect(await fs.readFile(cachedMarker, "utf-8")).toBe("v1") + + // Push a new commit to the branch + await runGit(["checkout", "feat/update-test"], repoRoot, gitEnv) + await fs.writeFile(markerPath, "v2") + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "update marker to v2"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + + // Second run: update + const second = await runPluginPath() + expect(second.stderr).toContain("Updating") + expect(await fs.readFile(cachedMarker, "utf-8")).toBe("v2") + }) + + test("fails with a clear error for a nonexistent branch", async () => { + const repoRoot = await createTestRepo() + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-noexist-")) + + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "compound-engineering", + "--branch", + "does-not-exist", + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + expect(exitCode).not.toBe(0) + }) + + test("produces distinct cache paths for branches that differ only by slash placement", async () => { + const repoRoot = await createTestRepo() + await runGit(["checkout", "-b", "feat/foo-bar"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + await runGit(["checkout", "-b", "feat-foo/bar"], repoRoot, gitEnv) + await runGit(["checkout", "main"], repoRoot, gitEnv) + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-collision-")) + + const runForBranch = async (branch: string) => { + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "compound-engineering", + "--branch", + branch, + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`CLI failed for branch '${branch}' (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + return stdout.trim() + } + + const path1 = await runForBranch("feat/foo-bar") + const path2 = await runForBranch("feat-foo/bar") + + expect(path1).not.toBe(path2) + expect(path1).toContain("feat~foo-bar") + expect(path2).toContain("feat-foo~bar") + }) + + test("fails when plugin name does not exist in the repo", async () => { + const repoRoot = await createTestRepo() + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-path-noplugin-")) + + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "plugin-path", + "nonexistent-plugin", + "--branch", + "main", + ], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...gitEnv, + HOME: tempHome, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + expect(exitCode).not.toBe(0) + expect(stderr).toContain("Plugin directory not found") + }) +})