Parent directory

executable_herdr-new-pi-workspace

7504 bytes
  1#!/usr/bin/env node
  2"use strict";
  3
  4const fs = require("node:fs/promises");
  5const os = require("node:os");
  6const path = require("node:path");
  7const { randomBytes } = require("node:crypto");
  8const { spawn, spawnSync } = require("node:child_process");
  9const readline = require("node:readline/promises");
 10const process = require("node:process");
 11
 12let prompt;
 13
 14class CommandFailure extends Error {
 15  constructor(command, args, result) {
 16    const stderr = result.stderr?.trim() || "No stderr output.";
 17    super(`Command failed: ${formatCommand(command, args)}\n${stderr}`);
 18  }
 19}
 20
 21function formatCommand(command, args) {
 22  return [command, ...args].map((value) => JSON.stringify(value)).join(" ");
 23}
 24
 25function run(command, args) {
 26  const result = spawnSync(command, args, {
 27    encoding: "utf8",
 28    env: process.env,
 29  });
 30
 31  if (result.error) {
 32    throw new Error(
 33      `Cannot run ${JSON.stringify(command)}: ${result.error.message}`,
 34    );
 35  }
 36
 37  if (result.status !== 0) {
 38    throw new CommandFailure(command, args, result);
 39  }
 40
 41  return result;
 42}
 43
 44function findHerdr() {
 45  const candidates = [];
 46  const configuredPath = process.env.HERDR_BIN_PATH?.trim();
 47
 48  if (configuredPath) {
 49    candidates.push(configuredPath);
 50  }
 51  candidates.push("herdr");
 52
 53  const failures = [];
 54  for (const candidate of [...new Set(candidates)]) {
 55    try {
 56      run(candidate, ["--version"]);
 57      return candidate;
 58    } catch (error) {
 59      failures.push(error.message);
 60    }
 61  }
 62
 63  throw new Error(
 64    `Cannot run Herdr. Tried ${[...new Set(candidates)].map((candidate) => JSON.stringify(candidate)).join(" and ")}.\n${failures.join("\n")}`,
 65  );
 66}
 67
 68function requirePi() {
 69  try {
 70    run("pi", ["--version"]);
 71  } catch (error) {
 72    throw new Error(`Cannot run pi from PATH.\n${error.message}`);
 73  }
 74}
 75
 76function requireBash() {
 77  try {
 78    run("bash", ["--version"]);
 79  } catch (error) {
 80    throw new Error(`Cannot run bash from PATH.\n${error.message}`);
 81  }
 82}
 83
 84function expandHome(directory) {
 85  return directory.startsWith("~/")
 86    ? path.join(os.homedir(), directory.slice(2))
 87    : directory;
 88}
 89
 90function promptForDirectory() {
 91  const result = spawnSync(
 92    "bash",
 93    [
 94      "-c",
 95      'IFS= read -e -r -p "Directory: " directory; status=$?; printf "%s" "$directory"; exit "$status"',
 96    ],
 97    {
 98      encoding: "utf8",
 99      env: process.env,
100      stdio: ["inherit", "pipe", "inherit"],
101    },
102  );
103
104  if (result.error) {
105    throw new Error(`Cannot read a directory: ${result.error.message}`);
106  }
107
108  return result.status === 0 ? result.stdout : null;
109}
110
111function queryZoxide(query) {
112  const keywords = query.split(/\s+/);
113
114  try {
115    return run("zoxide", ["query", "--", ...keywords]).stdout.trim();
116  } catch (error) {
117    console.error(
118      `No directory matches ${JSON.stringify(query)}. Enter a path or a zoxide query.`,
119    );
120    return null;
121  }
122}
123
124async function chooseDirectory() {
125  for (;;) {
126    const enteredDirectory = promptForDirectory();
127    if (enteredDirectory === null || !enteredDirectory.trim()) {
128      return null;
129    }
130
131    const trimmedDirectory = enteredDirectory.trim();
132    const pathDirectory = path.resolve(expandHome(trimmedDirectory));
133    let resolvedDirectory = pathDirectory;
134
135    try {
136      await fs.access(pathDirectory);
137    } catch {
138      resolvedDirectory = queryZoxide(trimmedDirectory);
139      if (!resolvedDirectory) {
140        continue;
141      }
142    }
143
144    let canonicalDirectory;
145    try {
146      canonicalDirectory = await fs.realpath(resolvedDirectory);
147    } catch {
148      console.error(
149        `Directory does not exist or cannot be read: ${resolvedDirectory}`,
150      );
151      continue;
152    }
153
154    try {
155      const status = await fs.stat(canonicalDirectory);
156      if (!status.isDirectory()) {
157        console.error(`Path is not a directory: ${canonicalDirectory}`);
158        continue;
159      }
160    } catch {
161      console.error(`Directory cannot be read: ${canonicalDirectory}`);
162      continue;
163    }
164
165    return canonicalDirectory;
166  }
167}
168
169function textPrompt() {
170  if (!prompt) {
171    prompt = readline.createInterface({
172      input: process.stdin,
173      output: process.stdout,
174    });
175  }
176  return prompt;
177}
178
179async function chooseWorkspaceName(directory) {
180  const defaultName = path.basename(directory);
181  const workspaceName = (
182    await textPrompt().question(`Workspace name [${defaultName}]: `)
183  ).trim();
184  return workspaceName || defaultName;
185}
186
187async function chooseShellTab() {
188  for (;;) {
189    const answer = (
190      await textPrompt().question("Open shell tab? [y/N]: ")
191    ).trim().toLowerCase();
192
193    if (!answer || answer === "n" || answer === "no") {
194      return false;
195    }
196    if (answer === "y" || answer === "yes") {
197      return true;
198    }
199
200    console.error("Enter y or n.");
201  }
202}
203
204function parseWorkspaceCreated(output) {
205  let response;
206  try {
207    response = JSON.parse(output);
208  } catch (error) {
209    throw new Error(
210      `Herdr returned invalid JSON for workspace creation: ${error.message}`,
211    );
212  }
213
214  const workspaceId = response?.result?.workspace?.workspace_id;
215  const tabId = response?.result?.tab?.tab_id;
216  const paneId = response?.result?.root_pane?.pane_id;
217
218  if (
219    response?.result?.type !== "workspace_created" ||
220    !workspaceId ||
221    !tabId ||
222    !paneId
223  ) {
224    throw new Error(
225      "Herdr returned an unexpected workspace creation response.",
226    );
227  }
228
229  return { workspaceId, tabId, paneId };
230}
231
232function startAgentInBackground(herdr, name, paneId) {
233  const child = spawn(
234    "bash",
235    [
236      "-c",
237      'sleep 0.2; exec "$@"',
238      "bash",
239      herdr,
240      "agent",
241      "start",
242      name,
243      "--kind",
244      "pi",
245      "--pane",
246      paneId,
247    ],
248    {
249      detached: true,
250      env: process.env,
251      stdio: "ignore",
252    },
253  );
254  child.once("error", () => {});
255  child.unref();
256}
257
258async function waitForEnter() {
259  try {
260    await textPrompt().question("\nPress Enter to close...");
261  } catch {}
262}
263
264async function main() {
265  let createdWorkspaceId;
266
267  try {
268    const herdr = findHerdr();
269    requirePi();
270    requireBash();
271
272    const directory = await chooseDirectory();
273    if (!directory) {
274      console.log("Cancelled.");
275      return;
276    }
277
278    const workspaceName = await chooseWorkspaceName(directory);
279    const openShellTab = await chooseShellTab();
280
281    const creation = parseWorkspaceCreated(
282      run(herdr, [
283        "workspace",
284        "create",
285        "--cwd",
286        directory,
287        "--label",
288        workspaceName,
289        "--focus",
290      ]).stdout,
291    );
292    createdWorkspaceId = creation.workspaceId;
293
294    run(herdr, ["tab", "rename", creation.tabId, "pi"]);
295
296    if (openShellTab) {
297      run(herdr, [
298        "tab",
299        "create",
300        "--workspace",
301        creation.workspaceId,
302        "--cwd",
303        directory,
304        "--label",
305        "shell",
306        "--no-focus",
307      ]);
308    }
309
310    const agentName = `pi-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
311    startAgentInBackground(herdr, agentName, creation.paneId);
312
313    console.log(
314      `Created workspace ${JSON.stringify(workspaceName)} (${creation.workspaceId}) with pi and shell tabs.`,
315    );
316  } catch (error) {
317    console.error(`\n${error.message}`);
318    if (createdWorkspaceId) {
319      console.error(
320        `Workspace ${createdWorkspaceId} was created and may need manual cleanup.`,
321      );
322    }
323    await waitForEnter();
324    process.exitCode = 1;
325  } finally {
326    prompt?.close();
327  }
328}
329
330main();