src / shell.ts
import { spawn } from "child_process";
import { existsSync } from "fs";
import { homedir, hostname, platform, userInfo } from "os";
export interface ShellSettings {
shell: string;
loginShell: boolean;
defaultCwd: string;
timeoutMs: number;
maxOutputBytes: number;
}
export interface RunOptions {
signal?: AbortSignal;
cwd?: string;
timeoutMs?: number;
env?: Record<string, string>;
}
export interface RunResult {
stdout: string;
stderr: string;
exitCode: number | null;
termSignal: NodeJS.Signals | null;
truncated: { stdout: boolean; stderr: boolean };
timedOut: boolean;
shell: string;
shellArgs: string[];
cwd: string;
}
let cachedAutoShell: string | null = null;
// LM Studio's plugin runtime does not inherit the user's interactive shell
// environment (PATH, SHELL, etc. are stripped down). To run "the user's shell"
// we have to discover it, then invoke it as a login shell so the user's
// profile is sourced — that is what gives us back their real PATH, version
// manager hooks, and so on.
function autoDetectShell(): string {
if (cachedAutoShell !== null) return cachedAutoShell;
const candidates: (string | undefined)[] = [];
// 1. $SHELL if the runtime did pass it through.
candidates.push(process.env.SHELL);
// 2. The shell recorded for this user in /etc/passwd. os.userInfo() reads
// via getpwuid_r, so this works even when env is stripped.
try {
const info = userInfo();
if (typeof (info as { shell?: unknown }).shell === "string") {
candidates.push((info as { shell?: string }).shell);
}
} catch {
// userInfo() can throw on some platforms — fall through to defaults.
}
// 3. Platform-appropriate fallbacks.
if (process.platform === "darwin") {
candidates.push("/bin/zsh", "/bin/bash", "/bin/sh");
} else if (process.platform === "win32") {
candidates.push(process.env.ComSpec, "C:\\Windows\\System32\\cmd.exe");
} else {
candidates.push("/bin/bash", "/bin/sh");
}
for (const c of candidates) {
if (typeof c === "string" && c.length > 0 && existsSync(c)) {
cachedAutoShell = c;
return c;
}
}
cachedAutoShell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
return cachedAutoShell;
}
export function resolveShell(configured: string): string {
const trimmed = configured.trim();
if (trimmed.length > 0 && existsSync(trimmed)) return trimmed;
return autoDetectShell();
}
export function resolveCwd(settings: ShellSettings, override?: string): string {
const candidate =
(override ?? "").trim() || settings.defaultCwd.trim() || homedir();
return existsSync(candidate) ? candidate : homedir();
}
function buildShellArgs(shellPath: string, loginShell: boolean, command: string): string[] {
const lower = shellPath.toLowerCase();
// Windows cmd.exe uses /d /s /c "command" — neither -l nor POSIX -c apply.
if (lower.endsWith("cmd.exe") || lower.endsWith("\\cmd")) {
return ["/d", "/s", "/c", command];
}
// PowerShell variants.
if (lower.endsWith("powershell.exe") || lower.endsWith("pwsh.exe") || lower.endsWith("pwsh")) {
return ["-NoProfile", "-Command", command];
}
// POSIX shells (sh, bash, zsh, dash, ksh, fish-ish).
return loginShell ? ["-l", "-c", command] : ["-c", command];
}
export async function runShell(
settings: ShellSettings,
command: string,
opts: RunOptions = {},
): Promise<RunResult> {
const shellPath = resolveShell(settings.shell);
const shellArgs = buildShellArgs(shellPath, settings.loginShell, command);
const cwd = resolveCwd(settings, opts.cwd);
const timeout = opts.timeoutMs ?? settings.timeoutMs;
const maxBytes = settings.maxOutputBytes;
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(shellPath, shellArgs, {
cwd,
env: { ...process.env, ...(opts.env ?? {}) },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
let stdoutTruncated = false;
let stderrTruncated = false;
let timedOut = false;
const appendCapped = (
chunk: Buffer,
current: string,
currentBytes: number,
): { text: string; bytes: number; truncated: boolean } => {
if (currentBytes >= maxBytes) {
return { text: current, bytes: currentBytes + chunk.length, truncated: true };
}
const remaining = maxBytes - currentBytes;
if (chunk.length <= remaining) {
return {
text: current + chunk.toString("utf-8"),
bytes: currentBytes + chunk.length,
truncated: false,
};
}
return {
text: current + chunk.subarray(0, remaining).toString("utf-8"),
bytes: currentBytes + chunk.length,
truncated: true,
};
};
child.stdout.on("data", (chunk: Buffer) => {
const r = appendCapped(chunk, stdout, stdoutBytes);
stdout = r.text;
stdoutBytes = r.bytes;
if (r.truncated) stdoutTruncated = true;
});
child.stderr.on("data", (chunk: Buffer) => {
const r = appendCapped(chunk, stderr, stderrBytes);
stderr = r.text;
stderrBytes = r.bytes;
if (r.truncated) stderrTruncated = true;
});
const killTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// Hard-kill if SIGTERM is ignored (common for shells that have spawned
// their own child still busy in a syscall).
const escalate = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 2000);
escalate.unref();
}, timeout);
const onAbort = () => {
child.kill("SIGTERM");
};
if (opts.signal !== undefined) {
if (opts.signal.aborted) {
child.kill("SIGTERM");
} else {
opts.signal.addEventListener("abort", onAbort, { once: true });
}
}
child.on("error", (err) => {
clearTimeout(killTimer);
if (opts.signal !== undefined) opts.signal.removeEventListener("abort", onAbort);
reject(err);
});
child.on("close", (code, signalName) => {
clearTimeout(killTimer);
if (opts.signal !== undefined) opts.signal.removeEventListener("abort", onAbort);
resolve({
stdout,
stderr,
exitCode: code,
termSignal: signalName,
truncated: { stdout: stdoutTruncated, stderr: stderrTruncated },
timedOut,
shell: shellPath,
shellArgs,
cwd,
});
});
});
}
export function formatRunResult(result: RunResult, maxBytes: number): string {
const parts: string[] = [];
if (result.timedOut) {
parts.push("[command timed out and was terminated]");
}
const exitDesc =
result.exitCode !== null
? `exit_code: ${result.exitCode}`
: `exit_code: null (terminated by signal ${result.termSignal ?? "unknown"})`;
parts.push(exitDesc);
if (result.stdout.length > 0) {
const tail = result.truncated.stdout
? `\n[stdout truncated at ${maxBytes} bytes]`
: "";
parts.push(`stdout:\n${result.stdout}${tail}`);
} else {
parts.push("stdout: (empty)");
}
if (result.stderr.length > 0) {
const tail = result.truncated.stderr
? `\n[stderr truncated at ${maxBytes} bytes]`
: "";
parts.push(`stderr:\n${result.stderr}${tail}`);
}
return parts.join("\n\n");
}
export interface ShellInfo {
shell: string;
shellArgsTemplate: string[];
loginShell: boolean;
defaultCwd: string;
user: string;
hostname: string;
platform: string;
homeDir: string;
}
export function describeEnvironment(settings: ShellSettings): ShellInfo {
const shellPath = resolveShell(settings.shell);
const argsTemplate = buildShellArgs(shellPath, settings.loginShell, "<COMMAND>");
const cwd = resolveCwd(settings);
let user = "unknown";
try {
user = userInfo().username;
} catch {
// ignore
}
return {
shell: shellPath,
shellArgsTemplate: argsTemplate,
loginShell: settings.loginShell,
defaultCwd: cwd,
user,
hostname: hostname(),
platform: platform(),
homeDir: homedir(),
};
}