/ extensions / inline-shell.ts
inline-shell.ts
  1  /**
  2   * Inline Shell Extension - expands inline shell commands in user prompts.
  3   *
  4   * Start pi with this extension:
  5   *   pi -e ./extensions/inline-shell.ts
  6   *
  7   * Then type prompts with inline shell:
  8   *   What's in !{pwd}?
  9   *   The current branch is !{git branch --show-current} and status: !{git status --short}
 10   *   My node version is !{node --version}
 11   *
 12   * The !{command} patterns are executed and replaced with their output before
 13   * the prompt is sent to the agent.
 14   *
 15   * Shell selection:
 16   * - If the current shell is zsh and $PI_CODING_AGENT_DIR/shell/pi-inline.zsh exists,
 17   *   source that file in a fresh zsh before running the command
 18   * - Otherwise, if the current shell is zsh, run a fresh interactive zsh so aliases
 19   *   and functions from .zshrc are available
 20   * - If the current shell is not zsh, fall back to bash
 21   *
 22   * The spawned shell always gets PI_INLINE_SHELL=1 so your shell config can skip
 23   * noisy prompt/plugin setup while still loading aliases/functions.
 24   *
 25   * Note: Regular !command syntax (whole-line bash) is preserved and works as before.
 26   */
 27  import { spawn } from "node:child_process";
 28  import { randomUUID } from "node:crypto";
 29  import * as fs from "node:fs";
 30  import * as os from "node:os";
 31  import * as path from "node:path";
 32  
 33  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 34  
 35  type ShellType = "zsh" | "bash";
 36  type ShellMode = "shared-zsh-config" | "interactive-zsh" | "interactive-bash" | "bash-fallback";
 37  
 38  type ResolvedShell = {
 39  	path: string;
 40  	type: ShellType;
 41  	mode: ShellMode;
 42  	sharedConfigPath?: string;
 43  };
 44  
 45  const PATTERN = /!\{([^}]+)\}/g;
 46  const TIMEOUT_MS = 30_000;
 47  const COMMON_BASH_PATHS = [
 48  	"/bin/bash",
 49  	"/usr/bin/bash",
 50  	"/usr/local/bin/bash",
 51  	"/opt/homebrew/bin/bash",
 52  ];
 53  
 54  function getAgentDir(): string {
 55  	const envDir = process.env.PI_CODING_AGENT_DIR;
 56  	if (envDir) {
 57  		if (envDir === "~") {
 58  			return os.homedir();
 59  		}
 60  		if (envDir.startsWith("~/")) {
 61  			return path.join(os.homedir(), envDir.slice(2));
 62  		}
 63  		return envDir;
 64  	}
 65  	return path.join(os.homedir(), ".pi", "agent");
 66  }
 67  
 68  function detectShellType(shellPath: string | undefined): ShellType | null {
 69  	if (!shellPath) {
 70  		return null;
 71  	}
 72  
 73  	const baseName = path.basename(shellPath).toLowerCase();
 74  	if (baseName === "zsh" || baseName.startsWith("zsh")) {
 75  		return "zsh";
 76  	}
 77  	if (baseName === "bash" || baseName.startsWith("bash")) {
 78  		return "bash";
 79  	}
 80  	return null;
 81  }
 82  
 83  function findFirstExistingPath(paths: string[]): string | null {
 84  	for (const candidate of paths) {
 85  		if (fs.existsSync(candidate)) {
 86  			return candidate;
 87  		}
 88  	}
 89  	return null;
 90  }
 91  
 92  function getSharedInlineZshConfigPath(): string | null {
 93  	const candidate = path.join(getAgentDir(), "shell", "pi-inline.zsh");
 94  	return fs.existsSync(candidate) ? candidate : null;
 95  }
 96  
 97  function resolveExecutionShell(): ResolvedShell {
 98  	const userShellPath = process.env.SHELL;
 99  	const userShellType = detectShellType(userShellPath);
100  	const hasUserShell = Boolean(userShellPath && fs.existsSync(userShellPath));
101  
102  	if (hasUserShell && userShellType === "zsh") {
103  		const sharedConfigPath = getSharedInlineZshConfigPath();
104  		if (sharedConfigPath) {
105  			return {
106  				path: userShellPath as string,
107  				type: "zsh",
108  				mode: "shared-zsh-config",
109  				sharedConfigPath,
110  			};
111  		}
112  
113  		return {
114  			path: userShellPath as string,
115  			type: "zsh",
116  			mode: "interactive-zsh",
117  		};
118  	}
119  
120  	if (hasUserShell && userShellType === "bash") {
121  		return {
122  			path: userShellPath as string,
123  			type: "bash",
124  			mode: "interactive-bash",
125  		};
126  	}
127  
128  	return {
129  		path: findFirstExistingPath(COMMON_BASH_PATHS) ?? "/bin/bash",
130  		type: "bash",
131  		mode: "bash-fallback",
132  	};
133  }
134  
135  function shellQuote(value: string): string {
136  	return `'${value.replace(/'/g, `'"'"'`)}'`;
137  }
138  
139  function unwrapBraceWrappedCommand(command: string): string | null {
140  	const trimmed = command.trim();
141  	if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
142  		return null;
143  	}
144  
145  	const inner = trimmed.slice(1, -1).trim();
146  	return inner.length > 0 ? inner : null;
147  }
148  
149  function buildShellScript(
150  	command: string,
151  	shell: ResolvedShell,
152  	options?: { startMarker?: string; endMarker?: string },
153  ): string {
154  	const scriptLines: string[] = ["export PI_INLINE_SHELL=1"];
155  
156  	if (options?.startMarker) {
157  		scriptLines.push(`printf '%s\\n' ${shellQuote(options.startMarker)}`);
158  		scriptLines.push(`printf '%s\\n' ${shellQuote(options.startMarker)} >&2`);
159  	}
160  
161  	if (shell.mode === "shared-zsh-config" && shell.sharedConfigPath) {
162  		scriptLines.push(`source ${shellQuote(shell.sharedConfigPath)}`);
163  	}
164  
165  	scriptLines.push("set +e");
166  	scriptLines.push(`eval -- ${shellQuote(command)}`);
167  	scriptLines.push("__pi_inline_status=$?");
168  
169  	if (options?.endMarker) {
170  		scriptLines.push(`printf '\\n%s\\n' ${shellQuote(options.endMarker)}`);
171  		scriptLines.push(`printf '\\n%s\\n' ${shellQuote(options.endMarker)} >&2`);
172  	}
173  
174  	scriptLines.push("exit $__pi_inline_status");
175  	return scriptLines.join("\n");
176  }
177  
178  function buildShellArgs(script: string, shell: ResolvedShell): string[] {
179  	return shell.mode === "shared-zsh-config"
180  		? ["-c", script]
181  		: ["-i", "-c", script];
182  }
183  
184  function createShellBackedBashOperations(shell: ResolvedShell, originalCommand?: string) {
185  	return {
186  		exec: async (
187  			command: string,
188  			cwd: string,
189  			options: {
190  				onData: (data: Buffer) => void;
191  				signal?: AbortSignal;
192  				timeout?: number;
193  				env?: NodeJS.ProcessEnv;
194  			},
195  		): Promise<{ exitCode: number | null }> => {
196  			const commandToRun = originalCommand ?? command;
197  			const effectiveCommand = unwrapBraceWrappedCommand(commandToRun) ?? commandToRun;
198  			const script = buildShellScript(effectiveCommand, shell);
199  			const child = spawn(shell.path, buildShellArgs(script, shell), {
200  				cwd,
201  				env: {
202  					...process.env,
203  					...options.env,
204  					PI_INLINE_SHELL: "1",
205  				},
206  				stdio: ["ignore", "pipe", "pipe"],
207  			});
208  
209  			child.stdout?.on("data", options.onData);
210  			child.stderr?.on("data", options.onData);
211  
212  			let timeoutHandle: NodeJS.Timeout | undefined;
213  			let settled = false;
214  
215  			const terminate = () => {
216  				if (child.killed) {
217  					return;
218  				}
219  				child.kill("SIGTERM");
220  				setTimeout(() => {
221  					if (!child.killed) {
222  						child.kill("SIGKILL");
223  					}
224  				}, 5000);
225  			};
226  
227  			const abortHandler = () => terminate();
228  			if (options.signal) {
229  				if (options.signal.aborted) {
230  					terminate();
231  				} else {
232  					options.signal.addEventListener("abort", abortHandler, { once: true });
233  				}
234  			}
235  
236  			if (options.timeout && options.timeout > 0) {
237  				timeoutHandle = setTimeout(() => terminate(), options.timeout * 1000);
238  			}
239  
240  			return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
241  				const cleanup = () => {
242  					if (settled) {
243  						return;
244  					}
245  					settled = true;
246  					if (timeoutHandle) {
247  						clearTimeout(timeoutHandle);
248  					}
249  					options.signal?.removeEventListener("abort", abortHandler);
250  				};
251  
252  				child.on("error", (error) => {
253  					cleanup();
254  					reject(error);
255  				});
256  				child.on("close", (code) => {
257  					cleanup();
258  					resolve({ exitCode: code });
259  				});
260  			});
261  		},
262  	};
263  }
264  
265  function buildShellInvocation(command: string, shell: ResolvedShell): {
266  	args: string[];
267  	startMarker: string;
268  	endMarker: string;
269  } {
270  	const startMarker = `__PI_INLINE_START_${randomUUID()}__`;
271  	const endMarker = `__PI_INLINE_END_${randomUUID()}__`;
272  	const scriptLines = [
273  		"export PI_INLINE_SHELL=1",
274  		`printf '%s\\n' ${shellQuote(startMarker)}`,
275  		`printf '%s\\n' ${shellQuote(startMarker)} >&2`,
276  	];
277  
278  	if (shell.mode === "shared-zsh-config" && shell.sharedConfigPath) {
279  		scriptLines.push(`source ${shellQuote(shell.sharedConfigPath)}`);
280  	}
281  
282  	scriptLines.push("set +e");
283  	scriptLines.push(`eval -- ${shellQuote(command)}`);
284  	scriptLines.push("__pi_inline_status=$?");
285  	scriptLines.push(`printf '\\n%s\\n' ${shellQuote(endMarker)}`);
286  	scriptLines.push(`printf '\\n%s\\n' ${shellQuote(endMarker)} >&2`);
287  	scriptLines.push("exit $__pi_inline_status");
288  
289  	const args = shell.mode === "shared-zsh-config"
290  		? ["-c", scriptLines.join("\n")]
291  		: ["-i", "-c", scriptLines.join("\n")];
292  
293  	return { args, startMarker, endMarker };
294  }
295  
296  function extractMarkedOutput(text: string, startMarker: string, endMarker: string): string {
297  	const normalized = text.replace(/\r\n/g, "\n");
298  	const startIndex = normalized.indexOf(startMarker);
299  	if (startIndex === -1) {
300  		return normalized.trim();
301  	}
302  
303  	let contentStart = startIndex + startMarker.length;
304  	if (normalized[contentStart] === "\n") {
305  		contentStart += 1;
306  	}
307  
308  	const endIndex = normalized.lastIndexOf(`\n${endMarker}`);
309  	if (endIndex === -1 || endIndex < contentStart) {
310  		return normalized.slice(contentStart).trim();
311  	}
312  
313  	return normalized.slice(contentStart, endIndex).trim();
314  }
315  
316  function getReplacementText(stdout: string, stderr: string, exitCode: number): string {
317  	const trimmedStdout = stdout.trim();
318  	const trimmedStderr = stderr.trim();
319  	if (trimmedStdout.length > 0) {
320  		return trimmedStdout;
321  	}
322  	if (trimmedStderr.length > 0) {
323  		return trimmedStderr;
324  	}
325  	if (exitCode !== 0) {
326  		return `[error: exit code ${exitCode}]`;
327  	}
328  	return "";
329  }
330  
331  function describeShell(shell: ResolvedShell): string {
332  	switch (shell.mode) {
333  		case "shared-zsh-config":
334  			return "zsh (shell/pi-inline.zsh)";
335  		case "interactive-zsh":
336  			return "zsh (.zshrc)";
337  		case "interactive-bash":
338  			return "bash (.bashrc)";
339  		case "bash-fallback":
340  			return "bash fallback";
341  		default:
342  			return shell.type;
343  	}
344  }
345  
346  export default function (pi: ExtensionAPI) {
347  	pi.on("user_bash", async (event) => {
348  		const shell = resolveExecutionShell();
349  		return {
350  			operations: createShellBackedBashOperations(shell, event.command),
351  		};
352  	});
353  
354  	pi.on("input", async (event, ctx) => {
355  		const text = event.text;
356  
357  		if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
358  			return { action: "continue" };
359  		}
360  
361  		if (!PATTERN.test(text)) {
362  			return { action: "continue" };
363  		}
364  
365  		PATTERN.lastIndex = 0;
366  
367  		let result = text;
368  		const shell = resolveExecutionShell();
369  		const expansions: Array<{ command: string; output: string; error?: string }> = [];
370  		const matches: Array<{ full: string; command: string }> = [];
371  
372  		let match = PATTERN.exec(text);
373  		while (match) {
374  			matches.push({ full: match[0], command: match[1] });
375  			match = PATTERN.exec(text);
376  		}
377  		PATTERN.lastIndex = 0;
378  
379  		for (const { full, command } of matches) {
380  			try {
381  				const invocation = buildShellInvocation(command, shell);
382  				const shellResult = await pi.exec(shell.path, invocation.args, {
383  					cwd: ctx.cwd,
384  					timeout: TIMEOUT_MS,
385  				});
386  
387  				const filteredStdout = extractMarkedOutput(shellResult.stdout, invocation.startMarker, invocation.endMarker);
388  				const filteredStderr = extractMarkedOutput(shellResult.stderr, invocation.startMarker, invocation.endMarker);
389  				const replacementText = getReplacementText(filteredStdout, filteredStderr, shellResult.code);
390  				const error = shellResult.code === 0 ? undefined : `exit code ${shellResult.code}`;
391  
392  				expansions.push({ command, output: replacementText, error });
393  				result = result.replace(full, () => replacementText);
394  			} catch (err) {
395  				const errorMsg = err instanceof Error ? err.message : String(err);
396  				expansions.push({ command, output: "", error: errorMsg });
397  				result = result.replace(full, () => `[error: ${errorMsg}]`);
398  			}
399  		}
400  
401  		if (ctx.hasUI && expansions.length > 0) {
402  			const summary = expansions
403  				.map((expansion) => {
404  					const status = expansion.error ? ` (${expansion.error})` : "";
405  					const preview = expansion.output.length > 50
406  						? `${expansion.output.slice(0, 50)}...`
407  						: expansion.output;
408  					return `!{${expansion.command}}${status} -> "${preview}"`;
409  				})
410  				.join("\n");
411  
412  			ctx.ui.notify(`Expanded ${expansions.length} inline command(s) via ${describeShell(shell)}:\n${summary}`, "info");
413  		}
414  
415  		return { action: "transform", text: result, images: event.images };
416  	});
417  }