import type { CommandResult } from "./process.js"; export interface WorktreeInfo { readonly path: string; readonly label: string; readonly branch: string | null; readonly openWorkspaceId: string | null; readonly isBare: boolean; readonly isDetached: boolean; readonly isPrunable: boolean; readonly isLinkedWorktree: boolean; } interface WorktreeSource { readonly repoName: string; readonly repoRoot: string; readonly sourceCheckoutPath: string; readonly sourceWorkspaceId: string | null; } export interface WorktreeListResult { readonly type: "worktree_list"; readonly source: WorktreeSource; readonly worktrees: readonly WorktreeInfo[]; } export interface WorktreeOpenedResult { readonly type: "worktree_created" | "worktree_opened"; readonly workspaceId: string; readonly tabId: string; readonly rootPaneId: string; readonly worktree: WorktreeInfo; readonly alreadyOpen: boolean; } export interface WorktreeRemovedResult { readonly type: "worktree_removed"; readonly workspaceId: string; readonly path: string; readonly forced: boolean; } /** A Herdr socket API error, reported as JSON on stderr with a stable machine-readable code. */ export class HerdrApiError extends Error { readonly code: string; constructor(code: string, message: string) { super(`Herdr rejected the request (${code}): ${message}`); this.name = "HerdrApiError"; this.code = code; } } function fail(detail: string): never { throw new Error(`Herdr returned an unreadable response: ${detail}.`); } function record(value: unknown, field: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { fail(`${field} is not an object`); } return value as Record; } function text(value: unknown, field: string): string { if (typeof value !== "string" || !value) { fail(`${field} is not a non-empty string`); } return value; } function optionalText(value: unknown, field: string): string | null { if (value === undefined || value === null) { return null; } return text(value, field); } function flag(value: unknown, field: string): boolean { if (typeof value !== "boolean") { fail(`${field} is not a boolean`); } return value; } /** * Herdr prints one JSON document per command: a success envelope carrying `result`, or an * error envelope carrying `error`. A non-zero exit with an error envelope is a rejected * request, not a broken CLI, so it keeps its own error type. */ export function parseEnvelope(result: CommandResult): Record { const raw = (result.stdout.trim() || result.stderr.trim()).trim(); if (!raw) { fail(`empty output (exit ${result.exitCode})`); } let document: unknown; try { document = JSON.parse(raw); } catch (error) { throw new Error(`Herdr returned output that is not JSON: ${raw.slice(0, 500)}`, { cause: error, }); } const envelope = record(document, "the response"); const failure = envelope.error; if (failure !== undefined) { const detail = record(failure, "error"); throw new HerdrApiError(text(detail.code, "error.code"), text(detail.message, "error.message")); } // A failed command must say why, so a result with no error field is not read as a success. if (result.exitCode !== 0) { fail(`a result with no error field and exit ${result.exitCode}`); } return record(envelope.result, "result"); } function parseWorktreeInfo(value: unknown, field: string): WorktreeInfo { const info = record(value, field); return { path: text(info.path, `${field}.path`), label: text(info.label, `${field}.label`), branch: optionalText(info.branch, `${field}.branch`), openWorkspaceId: optionalText(info.open_workspace_id, `${field}.open_workspace_id`), isBare: flag(info.is_bare, `${field}.is_bare`), isDetached: flag(info.is_detached, `${field}.is_detached`), isPrunable: flag(info.is_prunable, `${field}.is_prunable`), isLinkedWorktree: flag(info.is_linked_worktree, `${field}.is_linked_worktree`), }; } function assertType(payload: Record, expected: readonly string[]): string { const type = text(payload.type, "result.type"); if (!expected.includes(type)) { fail(`result.type is ${JSON.stringify(type)} instead of ${expected.join(" or ")}`); } return type; } export function parseWorktreeList(payload: Record): WorktreeListResult { assertType(payload, ["worktree_list"]); const source = record(payload.source, "result.source"); const worktrees = payload.worktrees; if (!Array.isArray(worktrees)) { fail("result.worktrees is not an array"); } return { type: "worktree_list", source: { repoName: text(source.repo_name, "result.source.repo_name"), repoRoot: text(source.repo_root, "result.source.repo_root"), sourceCheckoutPath: text(source.source_checkout_path, "result.source.source_checkout_path"), sourceWorkspaceId: optionalText( source.source_workspace_id, "result.source.source_workspace_id", ), }, worktrees: worktrees.map((entry, index) => parseWorktreeInfo(entry, `result.worktrees[${index}]`), ), }; } export function parseWorktreeOpened(payload: Record): WorktreeOpenedResult { const type = assertType(payload, ["worktree_created", "worktree_opened"]); const workspace = record(payload.workspace, "result.workspace"); const tab = record(payload.tab, "result.tab"); const rootPane = record(payload.root_pane, "result.root_pane"); return { type: type as WorktreeOpenedResult["type"], workspaceId: text(workspace.workspace_id, "result.workspace.workspace_id"), tabId: text(tab.tab_id, "result.tab.tab_id"), rootPaneId: text(rootPane.pane_id, "result.root_pane.pane_id"), worktree: parseWorktreeInfo(payload.worktree, "result.worktree"), alreadyOpen: type === "worktree_opened" ? flag(payload.already_open, "result.already_open") : false, }; } export function parseWorktreeRemoved(payload: Record): WorktreeRemovedResult { assertType(payload, ["worktree_removed"]); return { type: "worktree_removed", workspaceId: text(payload.workspace_id, "result.workspace_id"), path: text(payload.path, "result.path"), forced: flag(payload.forced, "result.forced"), }; }