feat: add branch-based plugin install for worktree workflows (#395)
This commit is contained in:
201
README.md
201
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.
|
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
|
```bash
|
||||||
/plugin marketplace add EveryInc/compound-engineering-plugin
|
/plugin marketplace add EveryInc/compound-engineering-plugin
|
||||||
/plugin install compound-engineering
|
/plugin install compound-engineering
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cursor Install
|
### Cursor
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/add-plugin compound-engineering
|
/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.
|
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
|
bunx @every-env/compound-plugin install compound-engineering --to all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development
|
<details>
|
||||||
|
<summary>Output format details per target</summary>
|
||||||
|
|
||||||
|
| 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/<plugin>/` | 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/<plugin>/` | 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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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:
|
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
|
```bash
|
||||||
# add to ~/.zshrc or ~/.bashrc
|
# 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.
|
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
|
```bash
|
||||||
bun run src/index.ts install ./plugins/compound-engineering --to codex
|
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
|
```bash
|
||||||
bun run src/index.ts install ./plugins/compound-engineering --to opencode
|
bun run src/index.ts install ./plugins/compound-engineering --to opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
---
|
||||||
<summary>Output format details per target</summary>
|
|
||||||
|
|
||||||
| 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/<plugin>/` | 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/<plugin>/` | 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.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Sync Personal Config
|
## Sync Personal Config
|
||||||
|
|
||||||
@@ -180,41 +291,3 @@ Notes:
|
|||||||
- Droid, Windsurf, Kiro, and Qwen sync merge MCP servers into the provider's documented user config.
|
- 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.
|
- 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)
|
|
||||||
|
|||||||
@@ -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 <plugin>` 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/<plugin>-<sanitized-branch>/`
|
||||||
|
- 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 <name> <source> <dest>`
|
||||||
|
- Re-run: `git fetch origin <branch>` + `git reset --hard origin/<branch>`
|
||||||
|
|
||||||
|
### 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 <name>` 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/<purpose>/` 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.
|
||||||
@@ -78,6 +78,10 @@ export default defineCommand({
|
|||||||
default: true,
|
default: true,
|
||||||
description: "Infer agent temperature from name/description",
|
description: "Infer agent temperature from name/description",
|
||||||
},
|
},
|
||||||
|
branch: {
|
||||||
|
type: "string",
|
||||||
|
description: "Git branch to clone from (e.g. feat/new-agents)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const targetName = String(args.to)
|
const targetName = String(args.to)
|
||||||
@@ -87,7 +91,8 @@ export default defineCommand({
|
|||||||
throw new Error(`Unknown permissions mode: ${permissions}`)
|
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 {
|
try {
|
||||||
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
||||||
@@ -225,7 +230,7 @@ type ResolvedPluginPath = {
|
|||||||
cleanup?: () => Promise<void>
|
cleanup?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
|
async function resolvePluginPath(input: string, branch?: string): Promise<ResolvedPluginPath> {
|
||||||
// Only treat as a local path if it explicitly looks like one
|
// Only treat as a local path if it explicitly looks like one
|
||||||
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
|
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
|
||||||
const expanded = expandHome(input)
|
const expanded = expandHome(input)
|
||||||
@@ -234,13 +239,16 @@ async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
|
|||||||
throw new Error(`Local plugin path not found: ${directPath}`)
|
throw new Error(`Local plugin path not found: ${directPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundledPluginPath = await resolveBundledPluginPath(input)
|
// Skip bundled plugins when a branch is specified — the user wants a specific remote version
|
||||||
if (bundledPluginPath) {
|
if (!branch) {
|
||||||
return { path: bundledPluginPath }
|
const bundledPluginPath = await resolveBundledPluginPath(input)
|
||||||
|
if (bundledPluginPath) {
|
||||||
|
return { path: bundledPluginPath }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, fetch the latest from GitHub
|
// Otherwise, fetch from GitHub (optionally from a specific branch)
|
||||||
return await resolveGitHubPluginPath(input)
|
return await resolveGitHubPluginPath(input, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExtraTargets(value: unknown): string[] {
|
function parseExtraTargets(value: unknown): string[] {
|
||||||
@@ -271,11 +279,11 @@ async function resolveBundledPluginPath(pluginName: string): Promise<string | nu
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGitHubPluginPath(pluginName: string): Promise<ResolvedPluginPath> {
|
async function resolveGitHubPluginPath(pluginName: string, branch?: string): Promise<ResolvedPluginPath> {
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
|
||||||
const source = resolveGitHubSource()
|
const source = resolveGitHubSource()
|
||||||
try {
|
try {
|
||||||
await cloneGitHubRepo(source, tempRoot)
|
await cloneGitHubRepo(source, tempRoot, branch)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||||
throw error
|
throw error
|
||||||
@@ -301,8 +309,11 @@ function resolveGitHubSource(): string {
|
|||||||
return "https://github.com/EveryInc/compound-engineering-plugin"
|
return "https://github.com/EveryInc/compound-engineering-plugin"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloneGitHubRepo(source: string, destination: string): Promise<void> {
|
async function cloneGitHubRepo(source: string, destination: string, branch?: string): Promise<void> {
|
||||||
const proc = Bun.spawn(["git", "clone", "--depth", "1", source, destination], {
|
const args = ["git", "clone", "--depth", "1"]
|
||||||
|
if (branch) args.push("--branch", branch)
|
||||||
|
args.push(source, destination)
|
||||||
|
const proc = Bun.spawn(args, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
107
src/commands/plugin-path.ts
Normal file
107
src/commands/plugin-path.ts
Normal file
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(p)
|
||||||
|
return stat.isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneBranch(source: string, destination: string, branch: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import packageJson from "../package.json"
|
|||||||
import convert from "./commands/convert"
|
import convert from "./commands/convert"
|
||||||
import install from "./commands/install"
|
import install from "./commands/install"
|
||||||
import listCommand from "./commands/list"
|
import listCommand from "./commands/list"
|
||||||
|
import pluginPath from "./commands/plugin-path"
|
||||||
import sync from "./commands/sync"
|
import sync from "./commands/sync"
|
||||||
|
|
||||||
const main = defineCommand({
|
const main = defineCommand({
|
||||||
@@ -16,6 +17,7 @@ const main = defineCommand({
|
|||||||
convert: () => convert,
|
convert: () => convert,
|
||||||
install: () => install,
|
install: () => install,
|
||||||
list: () => listCommand,
|
list: () => listCommand,
|
||||||
|
"plugin-path": () => pluginPath,
|
||||||
sync: () => sync,
|
sync: () => sync,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -282,6 +282,68 @@ describe("CLI", () => {
|
|||||||
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
|
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 () => {
|
test("convert writes OpenCode output", async () => {
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-"))
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-"))
|
||||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||||
|
|||||||
295
tests/plugin-path.test.ts
Normal file
295
tests/plugin-path.test.ts
Normal file
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGit(args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user