feat: add branch-based plugin install for worktree workflows (#395)

This commit is contained in:
Trevin Chow
2026-03-26 11:01:56 -07:00
committed by GitHub
parent 4b44a94e23
commit e09a7426be
7 changed files with 755 additions and 75 deletions

View File

@@ -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<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
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
const expanded = expandHome(input)
@@ -234,13 +239,16 @@ async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
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<string | nu
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 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<void> {
const proc = Bun.spawn(["git", "clone", "--depth", "1", source, destination], {
async function cloneGitHubRepo(source: string, destination: string, branch?: string): Promise<void> {
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",
})

107
src/commands/plugin-path.ts Normal file
View 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"
}

View File

@@ -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,
},
})