src / shell.ts
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export interface CommandResult {
ok: boolean;
stdout: string;
stderr: string;
/** Populated only when the command failed to run or exited non-zero. */
error: string;
timedOut: boolean;
}
export interface RunOptions {
cwd: string;
timeoutMs: number;
maxBuffer: number;
}
/**
* Runs a shell command and normalises success and failure into the same shape,
* so callers never have to try/catch. Every shell path in this plugin goes
* through here.
*/
/**
* Terminal colour codes are pure noise to a model and burn its context, so they
* are stripped from every command result before anything else sees them.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\[[0-9;]*[A-Za-z]/g, "").replace(/\][^]*/g, "");
}
export async function runCommand(command: string, options: RunOptions): Promise<CommandResult> {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd,
timeout: options.timeoutMs,
maxBuffer: options.maxBuffer,
windowsHide: true,
});
return { ok: true, stdout: stripAnsi(stdout), stderr: stripAnsi(stderr), error: "", timedOut: false };
} catch (caught) {
const err = caught as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
killed?: boolean;
};
return {
ok: false,
stdout: stripAnsi(err.stdout ?? ""),
stderr: stripAnsi(err.stderr ?? ""),
error: stripAnsi(err.message),
timedOut: err.killed === true,
};
}
}
/** Renders a command result as compact text for the model to read. */
export function formatResult(result: CommandResult, timeoutSec: number): string {
if (result.timedOut) {
return `Timed out after ${timeoutSec}s and was killed.`;
}
const sections: string[] = [];
if (!result.ok) sections.push(`Command failed: ${result.error}`);
if (result.stdout.trim() !== "") sections.push(`stdout:\n${result.stdout.trim()}`);
if (result.stderr.trim() !== "") sections.push(`stderr:\n${result.stderr.trim()}`);
if (sections.length === 0) {
return result.ok ? "Command finished with no output." : "Command failed with no output.";
}
return sections.join("\n\n");
}
src / shell.ts
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export interface CommandResult {
ok: boolean;
stdout: string;
stderr: string;
/** Populated only when the command failed to run or exited non-zero. */
error: string;
timedOut: boolean;
}
export interface RunOptions {
cwd: string;
timeoutMs: number;
maxBuffer: number;
}
/**
* Runs a shell command and normalises success and failure into the same shape,
* so callers never have to try/catch. Every shell path in this plugin goes
* through here.
*/
/**
* Terminal colour codes are pure noise to a model and burn its context, so they
* are stripped from every command result before anything else sees them.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\[[0-9;]*[A-Za-z]/g, "").replace(/\][^]*/g, "");
}
export async function runCommand(command: string, options: RunOptions): Promise<CommandResult> {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd,
timeout: options.timeoutMs,
maxBuffer: options.maxBuffer,
windowsHide: true,
});
return { ok: true, stdout: stripAnsi(stdout), stderr: stripAnsi(stderr), error: "", timedOut: false };
} catch (caught) {
const err = caught as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
killed?: boolean;
};
return {
ok: false,
stdout: stripAnsi(err.stdout ?? ""),
stderr: stripAnsi(err.stderr ?? ""),
error: stripAnsi(err.message),
timedOut: err.killed === true,
};
}
}
/** Renders a command result as compact text for the model to read. */
export function formatResult(result: CommandResult, timeoutSec: number): string {
if (result.timedOut) {
return `Timed out after ${timeoutSec}s and was killed.`;
}
const sections: string[] = [];
if (!result.ok) sections.push(`Command failed: ${result.error}`);
if (result.stdout.trim() !== "") sections.push(`stdout:\n${result.stdout.trim()}`);
if (result.stderr.trim() !== "") sections.push(`stderr:\n${result.stderr.trim()}`);
if (sections.length === 0) {
return result.ok ? "Command finished with no output." : "Command failed with no output.";
}
return sections.join("\n\n");
}