feat: add branch-based plugin install for worktree workflows (#395)
This commit is contained in:
@@ -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
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 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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user