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();