import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; const execFileAsync = promisify(execFile); interface WorktreeEntry { branch?: string; is_bare: boolean; is_detached: boolean; is_linked_worktree: boolean; is_prunable: boolean; label?: string; open_workspace_id?: string; path: string; } interface WorktreeListResult { source: { repo_key: string; repo_name: string; repo_root: string; source_checkout_path: string; source_workspace_id: string; }; type: "worktree_list"; worktrees: WorktreeEntry[]; } interface WorktreeHooksConfig { /** Shell commands run (via `sh -c`) inside the new worktree after `herdr worktree create` succeeds. */ postCreate?: string[]; /** Shell commands run inside the worktree before `herdr worktree remove` executes. Failures do not block removal. */ preRemove?: string[]; /** Relative paths (files or dirs) to symlink from the source checkout into every new worktree, e.g. [".env.local"]. */ linkPaths?: string[]; } const CONFIG_PATH = ".pi/worktree.json"; async function loadHooksConfig(cwd: string): Promise { try { const raw = await readFile(join(cwd, CONFIG_PATH), "utf8"); return JSON.parse(raw) as WorktreeHooksConfig; } catch { return {}; } } async function runHerdrWorktree(args: string[]): Promise { const { stdout } = await execFileAsync("herdr", ["worktree", ...args, "--json"]); const parsed = JSON.parse(stdout); if (parsed.error) throw new Error(parsed.error.message ?? JSON.stringify(parsed.error)); return parsed.result; } async function runHooks(commands: string[] | undefined, cwd: string): Promise { if (!commands?.length) return []; const log: string[] = []; for (const command of commands) { try { const { stdout, stderr } = await execFileAsync("sh", ["-c", command], { cwd }); log.push(`$ ${command}\n${stdout}${stderr}`.trim()); } catch (err: any) { log.push(`$ ${command}\nFAILED: ${err.message}`); } } return log; } async function linkPaths(paths: string[] | undefined, sourceRoot: string, targetPath: string): Promise { if (!paths?.length) return []; const log: string[] = []; for (const rel of paths) { try { await execFileAsync("ln", ["-sf", join(sourceRoot, rel), join(targetPath, rel)]); log.push(`linked ${rel}`); } catch (err: any) { log.push(`failed to link ${rel}: ${err.message}`); } } return log; } export default function pluginHerdrWorktree(pi: ExtensionAPI) { pi.registerTool({ name: "herdr_worktree_list", label: "List Worktrees", description: "List Git worktree workspaces for the current repository via Herdr's native worktree API. " + "Shows branch, path, detached/bare/prunable state, and which Herdr workspace (if any) has it open.", parameters: Type.Object({ workspace: Type.Optional(Type.String({ description: "Filter to a specific Herdr workspace ID." })), cwd: Type.Optional(Type.String({ description: "Repo directory context. Defaults to the caller's cwd." })), }) as any, async execute(_toolCallId, params) { const args = ["list"]; if (params.workspace) args.push("--workspace", params.workspace); if (params.cwd) args.push("--cwd", params.cwd); const result = (await runHerdrWorktree(args)) as WorktreeListResult; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, }); pi.registerTool({ name: "herdr_worktree_create", label: "Create Worktree", description: "Create a Git worktree and open it in a Herdr pane, via `herdr worktree create`. " + "After Herdr creates the worktree, runs postCreate hooks and linkPaths symlinks from " + `${CONFIG_PATH} (if present) inside the new worktree directory before returning.`, parameters: Type.Object({ workspace: Type.Optional(Type.String({ description: "Herdr workspace ID to open the new pane in. Defaults to the caller's workspace." })), cwd: Type.Optional(Type.String({ description: "Repo directory to create the worktree from. Defaults to the caller's cwd." })), branch: Type.Optional(Type.String({ description: "New branch name to create and check out." })), base: Type.Optional(Type.String({ description: "Ref to branch from, defaults to the current branch." })), path: Type.Optional(Type.String({ description: "Explicit worktree path. Herdr picks a default if omitted." })), label: Type.Optional(Type.String({ description: "Display label for the new Herdr pane/workspace." })), focus: Type.Optional(Type.Boolean({ description: "Focus the new pane after creation. Defaults to Herdr's own default." })), }) as any, async execute(_toolCallId, params) { const args = ["create"]; if (params.workspace) args.push("--workspace", params.workspace); if (params.cwd) args.push("--cwd", params.cwd); if (params.branch) args.push("--branch", params.branch); if (params.base) args.push("--base", params.base); if (params.path) args.push("--path", params.path); if (params.label) args.push("--label", params.label); if (params.focus === true) args.push("--focus"); if (params.focus === false) args.push("--no-focus"); const result = await runHerdrWorktree(args); const worktreePath: string | undefined = result?.path ?? result?.worktree?.path; const sourceRoot: string = result?.source?.repo_root ?? process.cwd(); let hookLog: string[] = []; let linkLog: string[] = []; if (worktreePath) { const config = await loadHooksConfig(sourceRoot); linkLog = await linkPaths(config.linkPaths, sourceRoot, worktreePath); hookLog = await runHooks(config.postCreate, worktreePath); } return { content: [ { type: "text", text: JSON.stringify({ herdr: result, linked: linkLog, postCreate: hookLog }, null, 2), }, ], }; }, }); pi.registerTool({ name: "herdr_worktree_open", label: "Open Worktree", description: "Open an existing Git worktree in a Herdr pane, via `herdr worktree open`.", parameters: Type.Object({ workspace: Type.Optional(Type.String({ description: "Herdr workspace ID to open the pane in. Defaults to the caller's workspace." })), cwd: Type.Optional(Type.String({ description: "Repo directory context. Defaults to the caller's cwd." })), path: Type.Optional(Type.String({ description: "Worktree path to open." })), branch: Type.Optional(Type.String({ description: "Open the worktree checked out on this branch instead of by path." })), label: Type.Optional(Type.String()), focus: Type.Optional(Type.Boolean()), }) as any, async execute(_toolCallId, params) { const args = ["open"]; if (params.workspace) args.push("--workspace", params.workspace); if (params.cwd) args.push("--cwd", params.cwd); if (params.path) args.push("--path", params.path); if (params.branch) args.push("--branch", params.branch); if (params.label) args.push("--label", params.label); if (params.focus === true) args.push("--focus"); if (params.focus === false) args.push("--no-focus"); const result = await runHerdrWorktree(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, }); pi.registerTool({ name: "herdr_worktree_remove", label: "Remove Worktree", description: "Remove a Git worktree checkout via `herdr worktree remove`. Herdr's remove command targets a " + "Herdr workspace ID, not a filesystem path, so pass either `workspace` directly, or `path` and this " + "tool resolves it to a workspace ID via `herdr worktree list` first (failing if the path has no open workspace). " + `Runs preRemove hooks from ${CONFIG_PATH} (if present) before removal, using the resolved path; hook failures do not block removal.`, parameters: Type.Object({ workspace: Type.Optional(Type.String({ description: "Herdr workspace ID of the open worktree to remove." })), path: Type.Optional(Type.String({ description: "Worktree path to remove. Resolved to a workspace ID via herdr worktree list if `workspace` is omitted." })), force: Type.Optional(Type.Boolean({ description: "Force removal even with uncommitted changes." })), }) as any, async execute(_toolCallId, params) { if (!params.workspace && !params.path) { throw new Error("herdr_worktree_remove requires either workspace or path."); } let workspaceId = params.workspace; let resolvedPath = params.path; if (!workspaceId) { const list = (await runHerdrWorktree(["list"])) as WorktreeListResult; const match = list.worktrees.find((w) => w.path === params.path); if (!match) throw new Error(`No worktree found at path ${params.path}.`); if (!match.open_workspace_id) { throw new Error(`Worktree at ${params.path} has no open Herdr workspace; open it first with herdr_worktree_open.`); } workspaceId = match.open_workspace_id; } const hookLog = resolvedPath ? await runHooks((await loadHooksConfig(process.cwd())).preRemove, resolvedPath) : []; const removeArgs = ["remove", "--workspace", workspaceId]; if (params.force) removeArgs.push("--force"); const result = await runHerdrWorktree(removeArgs); return { content: [{ type: "text", text: JSON.stringify({ herdr: result, preRemove: hookLog }, null, 2) }], }; }, }); pi.registerCommand("worktree", { description: "List Git worktrees for the current repo (Herdr-backed). Use the herdr_worktree_* tools for create/open/remove.", handler: async () => { try { const result = (await runHerdrWorktree(["list"])) as WorktreeListResult; const lines = result.worktrees.map((w) => { const flags = [w.is_detached && "detached", w.is_prunable && "prunable", w.is_bare && "bare"] .filter(Boolean) .join(", "); return `- ${w.branch ?? "(detached)"} — ${w.path}${w.open_workspace_id ? ` [open: ${w.open_workspace_id}]` : ""}${flags ? ` (${flags})` : ""}`; }); pi.sendUserMessage(`Worktrees for ${result.source.repo_name}:\n${lines.join("\n") || "(none)"}`); } catch (err: any) { pi.sendUserMessage(`Failed to list worktrees: ${err.message}`); } }, }); }