repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/nu-bang/index.ts
Rawimport { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type BashOperations,
type ExtensionAPI,
truncateTail,
} from "@earendil-works/pi-coding-agent";
import type {
AutocompleteItem,
AutocompleteProvider,
} from "@earendil-works/pi-tui";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
interface ProcessResult {
stdout: string;
stderr: string;
output: string;
code: number | undefined;
signal: NodeJS.Signals | null;
}
interface BangCommand {
command: string;
prefixLength: number;
}
interface NuExecutionResult {
output: string;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
}
const DEFAULT_NU_COMMAND = "nu";
const COMMAND_SETTING: SettingDeclaration<string> = {
key: "nu-bang.command",
env: "PI_NU_BANG_COMMAND",
default: DEFAULT_NU_COMMAND,
parse: (raw) => (typeof raw === "string" && raw.trim() ? raw : undefined),
};
// Non-login Nushell avoids sourcing user login startup for every bang command.
// That keeps execution deterministic and removes the dominant per-test/process cost.
const NU_BASE_ARGS = ["--no-history", "--error-style", "fancy"] as const;
const NU_TABLE_MODE = "none";
const TEMP_PREFIX = "pi-nu-bang-";
const TEMP_FILE_NAME = "command.nu";
const FULL_OUTPUT_PREFIX = "pi-nu-bang-output-";
const EXIT_STDIO_GRACE_MS = 100;
const tempDirs = new Set<string>();
function nuBaseArgs(): string[] {
return [...NU_BASE_ARGS];
}
function nuExecArgs(command: string): string[] {
return [...nuBaseArgs(), "--table-mode", NU_TABLE_MODE, "-c", command];
}
function fullOutputPath(): string {
return join(
tmpdir(),
`${FULL_OUTPUT_PREFIX}${randomBytes(8).toString("hex")}.log`,
);
}
async function persistFullOutput(output: string): Promise<string> {
const path = fullOutputPath();
await writeFile(path, output, { encoding: "utf8", mode: 0o600 });
return path;
}
function escapeControlChars(text: string): string {
return text.replace(
/[\x00-\x1F\x7F-\x9F]/g,
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
);
}
function stripTerminalControls(output: string): string {
return output
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\|\u009C)/g, "")
.replace(/\u009D[\s\S]*?(?:\x07|\x1B\\|\u009C)/g, "")
.replace(/\x1B[PX^_][\s\S]*?(?:\x1B\\|\u009C)/g, "")
.replace(/[\u0090\u0098\u009E\u009F][\s\S]*?(?:\x1B\\|\u009C)/g, "")
.replace(/\x1B[PX^_][^\r\n]*(?=\r?\n|$)/g, "")
.replace(/\x1B\][^\r\n]*(?=\r?\n|$)/g, "")
.replace(/[\u0090\u0098\u009D\u009E\u009F][^\r\n]*(?=\r?\n|$)/g, "")
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\u009B[0-?]*[ -/]*[@-~]/g, "")
.replace(/\x1B[@-Z\\-_]/g, "")
.replace(/[\x00-\x08\x0B-\x0D\x0E-\x1F\x7F-\x9F]/g, "");
}
function truncateOutput(output: string): {
output: string;
truncated: boolean;
} {
const truncated = truncateTail(output);
if (!truncated.truncated)
return { output: truncated.content, truncated: false };
const reason =
truncated.truncatedBy === "lines"
? `${truncated.outputLines}/${truncated.totalLines} lines`
: `${truncated.outputBytes}/${truncated.totalBytes} bytes`;
return {
output: `${truncated.content}\n… (truncated, showing tail: ${reason})`,
truncated: true,
};
}
function nodeErrorCode(error: unknown): string | undefined {
return error instanceof Error &&
"code" in error &&
typeof error.code === "string"
? error.code
: undefined;
}
async function workingDirectoryError(cwd: string): Promise<string | undefined> {
const safeCwd = escapeControlChars(cwd);
try {
const info = await stat(cwd);
if (!info.isDirectory())
return `Working directory is not a directory: ${safeCwd}`;
try {
await access(cwd, constants.X_OK);
return undefined;
} catch (error) {
const code = nodeErrorCode(error);
if (code === "EACCES") {
return `Working directory is not accessible: ${safeCwd}`;
}
if (code)
return `Working directory cannot be checked: ${safeCwd} (${escapeControlChars(code)})`;
throw error;
}
} catch (error) {
const code = nodeErrorCode(error);
if (code === "ENOENT") {
return `Working directory does not exist: ${safeCwd}`;
}
if (code)
return `Working directory cannot be checked: ${safeCwd} (${escapeControlChars(code)})`;
throw error;
}
}
function waitForForegroundProcess(
child: ChildProcessWithoutNullStreams,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return new Promise((resolve, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let exitSignal: NodeJS.Signals | null = null;
let postExitTimer: ReturnType<typeof setTimeout> | undefined;
let stdoutEnded = false;
let stderrEnded = false;
function cleanup(): void {
if (postExitTimer) clearTimeout(postExitTimer);
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout.removeListener("end", onStdoutEnd);
child.stderr.removeListener("end", onStderrEnd);
}
function finalize(
code: number | null,
processSignal: NodeJS.Signals | null,
): void {
if (settled) return;
settled = true;
cleanup();
child.stdout.destroy();
child.stderr.destroy();
resolve({ code, signal: processSignal });
}
function maybeFinalizeAfterExit(): void {
if (exited && stdoutEnded && stderrEnded) finalize(exitCode, exitSignal);
}
function onStdoutEnd(): void {
stdoutEnded = true;
maybeFinalizeAfterExit();
}
function onStderrEnd(): void {
stderrEnded = true;
maybeFinalizeAfterExit();
}
function onError(error: Error): void {
if (settled) return;
settled = true;
cleanup();
reject(error);
}
function onExit(
code: number | null,
processSignal: NodeJS.Signals | null,
): void {
exited = true;
exitCode = code;
exitSignal = processSignal;
maybeFinalizeAfterExit();
if (!settled)
postExitTimer ??= setTimeout(
() => finalize(code, processSignal),
EXIT_STDIO_GRACE_MS,
);
}
function onClose(
code: number | null,
processSignal: NodeJS.Signals | null,
): void {
finalize(code, processSignal);
}
child.stdout.once("end", onStdoutEnd);
child.stderr.once("end", onStderrEnd);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
});
}
function runProcess(
nuCommand: string,
args: string[],
cwd: string,
signal?: AbortSignal,
): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let output = "";
runStreamingProcess(nuCommand, args, cwd, {
onStdout(chunk) {
const text = chunk.toString("utf8");
stdout += text;
output += text;
},
onStderr(chunk) {
const text = chunk.toString("utf8");
stderr += text;
output += text;
},
signal,
})
.then((result) =>
resolve({
stdout,
stderr,
output,
code: result.exitCode ?? undefined,
signal: result.signal,
}),
)
.catch(reject);
});
}
function runStreamingProcess(
nuCommand: string,
args: string[],
cwd: string,
options: {
onStdout?: (chunk: Buffer) => void;
onStderr?: (chunk: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
env?: NodeJS.ProcessEnv;
},
): Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }> {
return new Promise((resolve, reject) => {
let aborted = options.signal?.aborted ?? false;
let timedOut = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
const detached = process.platform !== "win32";
const child = spawn(nuCommand, args, {
cwd,
detached,
env: options.env ?? process.env,
stdio: ["ignore", "pipe", "pipe"],
});
function killProcessGroup(kind: NodeJS.Signals): void {
if (child.pid === undefined) return;
try {
if (detached) process.kill(-child.pid, kind);
else child.kill(kind);
} catch {
try {
child.kill(kind);
} catch {}
}
}
function abortProcess(): void {
aborted = true;
killProcessGroup("SIGTERM");
forceKillTimer ??= setTimeout(() => killProcessGroup("SIGKILL"), 250);
}
function cleanup(): void {
if (forceKillTimer) clearTimeout(forceKillTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
options.signal?.removeEventListener("abort", abortProcess);
}
if (options.timeout !== undefined && options.timeout > 0) {
timeoutTimer = setTimeout(() => {
timedOut = true;
killProcessGroup("SIGTERM");
forceKillTimer ??= setTimeout(() => killProcessGroup("SIGKILL"), 250);
}, options.timeout * 1000);
}
child.stdout.on("data", (chunk: Buffer) => options.onStdout?.(chunk));
child.stderr.on("data", (chunk: Buffer) => options.onStderr?.(chunk));
waitForForegroundProcess(child as unknown as ChildProcessWithoutNullStreams)
.then((result) => {
cleanup();
if (timedOut) {
reject(new Error(`timeout:${options.timeout}`));
return;
}
resolve({
exitCode: result.code,
signal: aborted ? "SIGTERM" : result.signal,
});
})
.catch((error: unknown) => {
cleanup();
if (aborted) {
resolve({ exitCode: null, signal: "SIGTERM" });
return;
}
reject(error);
});
if (options.signal) {
options.signal.addEventListener("abort", abortProcess, { once: true });
if (options.signal.aborted) abortProcess();
}
});
}
async function withTempNuFile<T>(
code: string,
fn: (file: string) => Promise<T>,
): Promise<T> {
const dir = await mkdtemp(join(tmpdir(), TEMP_PREFIX));
tempDirs.add(dir);
try {
const file = join(dir, TEMP_FILE_NAME);
await writeFile(file, code, "utf8");
return await fn(file);
} finally {
tempDirs.delete(dir);
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
/** Return Nu source and prefix width for Pi's ! / !! input, or undefined for normal input. */
export function stripBangPrefix(text: string): BangCommand | undefined {
if (text.startsWith("!!")) return { command: text.slice(2), prefixLength: 2 };
if (text.startsWith("!")) return { command: text.slice(1), prefixLength: 1 };
return undefined;
}
export function cursorOffset(
lines: string[],
cursorLine: number,
cursorCol: number,
prefixLength: number,
): number | undefined {
if (cursorLine === 0 && cursorCol <= prefixLength) return undefined;
let offset = cursorCol;
for (let i = 0; i < cursorLine; i++) offset += (lines[i] ?? "").length + 1;
return Math.max(0, offset - prefixLength);
}
function completionToken(before: string): string {
let tokenStart = 0;
let quote: string | undefined;
let escaped = false;
for (let i = 0; i < before.length; i++) {
const char = before[i] ?? "";
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\" && quote !== "`") {
escaped = true;
continue;
}
if (char === quote) quote = undefined;
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (/[\s|;(){}[\]]/.test(char)) tokenStart = i + 1;
}
return before.slice(tokenStart);
}
export function completionPrefix(code: string, offset: number): string {
const token = completionToken(code.slice(0, offset));
const dot = token.lastIndexOf(".");
const isVariableCellPath = /^\$[^\s|;(){}[\]"'`]*$/.test(token);
return isVariableCellPath && dot >= 0 ? token.slice(dot + 1) : token;
}
function unescapeBackslashToken(value: string): string {
return value.replace(/\\(.)/g, "$1");
}
function escapedPathCompletionInput(
code: string,
offset: number,
prefix: string,
): { code: string; offset: number } | undefined {
if (!/\\./.test(prefix)) return undefined;
const start = offset - prefix.length;
if (start < 0) return undefined;
const quotedPrefix = "`" + unescapeBackslashToken(prefix);
return {
code: `${code.slice(0, start)}${quotedPrefix}${code.slice(offset)}`,
offset: start + quotedPrefix.length,
};
}
function unquoteCompletionValue(value: string): string {
const quote = value[0];
return (quote === "`" || quote === "'" || quote === '"') &&
value.endsWith(quote)
? value.slice(1, -1)
: value;
}
function filterEscapedPathCompletions(
values: string[],
prefix: string,
): string[] {
const unescapedPrefix = unescapeBackslashToken(prefix);
return values.filter((value) =>
unescapeBackslashToken(unquoteCompletionValue(value)).startsWith(
unescapedPrefix,
),
);
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function parseCompletionJson(text: string): string[] | undefined {
try {
const parsed = JSON.parse(text) as { completions?: unknown };
return stringArray(parsed.completions);
} catch {
return undefined;
}
}
function readQuotedItem(
body: string,
index: number,
): { value: string; next: number } {
let i = index;
while (body[i] === '"') i++;
let value = "";
while (i < body.length) {
const char = body[i];
if (char === "\\" && i + 1 < body.length) {
value += body[i + 1];
i += 2;
continue;
}
if (char === '"') {
let afterQuotes = i;
while (body[afterQuotes] === '"') afterQuotes++;
let next = afterQuotes;
while (/\s/.test(body[next] ?? "")) next++;
if (next >= body.length || body[next] === ",")
return { value, next: next + 1 };
value += body.slice(i, afterQuotes);
i = afterQuotes;
continue;
}
value += char;
i++;
}
return { value: value.trim(), next: i };
}
function parseMalformedCompletionList(text: string): string[] {
const start = text.indexOf("[");
const end = text.lastIndexOf("]");
if (start < 0 || end <= start) return [];
const body = text.slice(start + 1, end);
const completions: string[] = [];
let i = 0;
while (i < body.length) {
while (/[,\s]/.test(body[i] ?? "")) i++;
if (i >= body.length) break;
if (body[i] === '"') {
const item = readQuotedItem(body, i);
if (item.value) completions.push(item.value);
i = item.next;
continue;
}
const next = body.indexOf(",", i);
const endOfItem = next < 0 ? body.length : next;
const value = body.slice(i, endOfItem).trim();
if (value) completions.push(value);
i = endOfItem + 1;
}
return completions;
}
export function parseCompletionsOutput(stdout: string): string[] {
const text = stdout.trim();
if (!text) return [];
return (parseCompletionJson(text) ?? parseMalformedCompletionList(text)).map(
escapeControlChars,
);
}
export async function nuCompletions(
code: string,
offset: number,
cwd: string,
signal?: AbortSignal,
nuCommand = DEFAULT_NU_COMMAND,
): Promise<string[]> {
const cwdProblem = await workingDirectoryError(cwd);
if (cwdProblem) throw new Error(cwdProblem);
return withTempNuFile(code, async (file) => {
const result = await runProcess(
nuCommand,
[...nuBaseArgs(), "-I", cwd, "--ide-complete", String(offset), file],
cwd,
signal,
);
return parseCompletionsOutput(result.stdout);
});
}
export function createNuBashOperations(
fixedCommand?: string,
nuCommand = DEFAULT_NU_COMMAND,
): BashOperations {
return {
async exec(command, cwd, options) {
const finish = span?.("nu.execute", { kind: "process" });
try {
const cwdProblem = await workingDirectoryError(cwd);
if (cwdProblem) throw new Error(cwdProblem);
const result = await runStreamingProcess(
nuCommand,
nuExecArgs(fixedCommand ?? command),
cwd,
{
onStdout: options.onData,
onStderr: options.onData,
signal: options.signal,
timeout: options.timeout,
env: options.env,
},
);
finish?.("finish", {
kind: "process",
outcome:
result.signal !== null
? "cancelled"
: result.exitCode === 0
? "ok"
: "nonzero",
});
return { exitCode: result.exitCode };
} catch (error) {
finish?.("error", { kind: "process", outcome: "error" });
throw error;
}
},
};
}
export async function executeNu(
command: string,
cwd: string,
signal?: AbortSignal,
): Promise<NuExecutionResult> {
try {
const cwdProblem = await workingDirectoryError(cwd);
if (cwdProblem) throw new Error(cwdProblem);
const result = await runProcess(
DEFAULT_NU_COMMAND,
nuExecArgs(command),
cwd,
signal,
);
const output = stripTerminalControls(result.output);
const truncated = truncateOutput(output);
return {
output: truncated.output,
exitCode: result.code,
cancelled: result.signal !== null,
truncated: truncated.truncated,
fullOutputPath: truncated.truncated
? await persistFullOutput(output)
: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
output: `Nu execution failed: ${escapeControlChars(message)}`,
exitCode: 1,
cancelled: false,
truncated: false,
};
}
}
function fullText(lines: string[]): string {
return lines.join("\n");
}
function completionItems(values: string[]): AutocompleteItem[] {
return values.map((value) => ({ value, label: value }));
}
function matchingClosingQuote(prefix: string): string | undefined {
const quote = prefix[0];
return quote === '"' || quote === "'" || quote === "`" ? quote : undefined;
}
function replacePrefix(
lines: string[],
cursorLine: number,
cursorCol: number,
prefix: string,
value: string,
) {
const next = [...lines];
const line = next[cursorLine] ?? "";
const before = line.slice(0, cursorCol);
const after = line.slice(cursorCol);
const closingQuote = matchingClosingQuote(prefix);
const adjustedAfter =
closingQuote && after.startsWith(closingQuote) ? after.slice(1) : after;
next[cursorLine] =
before.slice(0, before.length - prefix.length) + value + adjustedAfter;
return {
lines: next,
cursorLine,
cursorCol: cursorCol - prefix.length + value.length,
};
}
export function wrapNuAutocompleteProvider(
current: AutocompleteProvider,
cwd: string,
nuCommand = DEFAULT_NU_COMMAND,
): AutocompleteProvider {
const fallbackItems = new WeakSet<AutocompleteItem>();
return {
async getSuggestions(lines, cursorLine, cursorCol, options) {
const bang = stripBangPrefix(fullText(lines));
if (!bang)
return current.getSuggestions(lines, cursorLine, cursorCol, options);
const offset = cursorOffset(
lines,
cursorLine,
cursorCol,
bang.prefixLength,
);
if (offset === undefined) return null;
try {
const prefix = completionPrefix(bang.command, offset);
const retry = escapedPathCompletionInput(bang.command, offset, prefix);
let completions: string[];
if (retry) {
// Escaped path prefixes need a quoted Nu retry.
// Run the retry first so the common path pays one Nu spawn, not two.
const retryCompletions = await nuCompletions(
retry.code,
retry.offset,
cwd,
options.signal,
nuCommand,
);
const source = retryCompletions.length
? retryCompletions
: await nuCompletions(
bang.command,
offset,
cwd,
options.signal,
nuCommand,
);
completions = filterEscapedPathCompletions(source, prefix);
} else {
completions = await nuCompletions(
bang.command,
offset,
cwd,
options.signal,
nuCommand,
);
}
const items = completionItems(completions);
return items.length === 0 ? null : { prefix, items };
} catch {
if (options.signal.aborted) return null;
const fallback = await current.getSuggestions(
lines,
cursorLine,
cursorCol,
options,
);
for (const item of fallback?.items ?? []) fallbackItems.add(item);
return fallback;
}
},
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
if (!stripBangPrefix(fullText(lines)) || fallbackItems.has(item))
return current.applyCompletion(
lines,
cursorLine,
cursorCol,
item,
prefix,
);
return replacePrefix(lines, cursorLine, cursorCol, prefix, item.value);
},
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
if (stripBangPrefix(fullText(lines))) return true;
return (
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
true
);
},
};
}
async function cleanupTempDirs(): Promise<void> {
for (const dir of [...tempDirs]) {
await rm(dir, { recursive: true, force: true }).catch(() => {});
tempDirs.delete(dir);
}
}
export default function (pi: ExtensionAPI) {
let nuCommand = DEFAULT_NU_COMMAND;
pi.on("session_shutdown", async () => {
dbg?.("session.shutdown");
try {
await cleanupTempDirs();
} finally {
closeDebug();
}
});
pi.on("session_start", (_event, ctx) => {
dbg?.("session.start", { mode: ctx.mode });
const setting = resolveSetting(pi, ctx, COMMAND_SETTING);
if (!setting.ok)
ctx.ui.notify(`${setting.error}; using ${DEFAULT_NU_COMMAND}`, "warning");
nuCommand = setting.ok ? setting.value : DEFAULT_NU_COMMAND;
ctx.ui.addAutocompleteProvider?.((current) =>
wrapNuAutocompleteProvider(current, ctx.cwd, nuCommand),
);
});
pi.on("user_bash", (event) => {
return { operations: createNuBashOperations(event.command, nuCommand) };
});
}