repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/nushell/index.ts
Rawimport { execFile } from "node:child_process";
import {
createBashToolDefinition,
type ExtensionAPI,
type ExtensionContext,
type ThemeColor,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { findNu, freshOperations, NuSession } from "./runtime.ts";
import { closeDebug, dbg } from "./src/debug.ts";
import {
parseBooleanSetting,
registerSettingFlag,
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
// Precedence: --nushell, PI_NUSHELL_ENABLE, trusted project, user, false.
const ENABLE_SETTING: SettingDeclaration<boolean> = {
key: "nushell.enable",
parse: parseBooleanSetting,
default: false,
flag: {
name: "nushell",
type: "boolean",
description:
"Enable Nushell tools at startup instead of bash and powershell",
},
env: "PI_NUSHELL_ENABLE",
};
const NU_COLORS: Record<number, ThemeColor> = {
32: "syntaxString",
33: "syntaxOperator",
34: "syntaxPunctuation",
35: "syntaxVariable",
36: "syntaxFunction",
90: "syntaxVariable",
96: "syntaxKeyword",
};
// Translate Nu's fixed ANSI palette into the active Pi theme at render time.
function themeNuHighlight(
ansi: string,
theme: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[1],
): string {
let output = "";
let start = 0;
let color: ThemeColor = "toolOutput";
for (const match of ansi.matchAll(/\x1b\[([0-9;]*)m/g)) {
output += theme.fg(color, ansi.slice(start, match.index));
const codes = match[1]?.split(";").map(Number) ?? [];
const foreground = codes.findLast(
(code) => (code >= 30 && code <= 37) || (code >= 90 && code <= 97),
);
color =
foreground === 35 && codes.includes(1)
? "syntaxPunctuation"
: (NU_COLORS[foreground ?? -1] ?? "toolOutput");
start = match.index + match[0].length;
}
return output + theme.fg(color, ansi.slice(start));
}
// Nu parses source without evaluating it; only the command heading is highlighted.
const highlightCache = new WeakMap<object, { command: string; text: string }>();
function renderNuCall(
args: { command: string; timeout?: number },
theme: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[1],
context: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[2],
label: string,
) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const command = typeof args?.command === "string" ? args.command : "";
let cached = highlightCache.get(state);
if (context.argsComplete && cached?.command !== command) {
const entry = { command, text: command };
cached = entry;
highlightCache.set(state, entry);
const binary = command && command.length <= 16_000 ? findNu() : undefined;
if (binary) {
execFile(
binary,
[
"--no-config-file",
"--no-history",
"-c",
"$env.PI_NU_HIGHLIGHT_INPUT | nu-highlight",
],
{
env: { ...process.env, PI_NU_HIGHLIGHT_INPUT: command },
encoding: "utf8",
timeout: 1000,
maxBuffer: 128 * 1024,
windowsHide: true,
},
(error, stdout) => {
if (error || highlightCache.get(state) !== entry) return;
entry.text = stdout.trimEnd();
context.invalidate();
},
);
}
}
const highlighted = cached?.command === command ? cached.text : command;
const text =
context.lastComponent instanceof Text
? context.lastComponent
: new Text("", 0, 0);
text.setText(
`${theme.fg("toolTitle", theme.bold(label))} ${themeNuHighlight(highlighted || "...", theme)}${args?.timeout ? theme.fg("muted", ` (timeout ${args.timeout}s)`) : ""}`,
);
return text;
}
export default function nushellExtension(pi: ExtensionAPI) {
const session = new NuSession();
let enabled = false;
let displaced: string[] = [];
registerSettingFlag(pi, ENABLE_SETTING);
function activate(ctx: ExtensionContext, quiet = false): void {
if (enabled) return;
if (!findNu()) {
ctx.ui.notify(
"Nushell executable not found on PATH; tools unchanged",
"warning",
);
dbg?.("tools.enable.error", { kind: "nu_not_found" });
return;
}
const available = new Set(pi.getAllTools().map((tool) => tool.name));
const missing = ["nu", "nu_session"].filter((name) => !available.has(name));
if (missing.length) {
ctx.ui.notify(
`Nushell tools excluded by tool allowlist: ${missing.join(", ")}; tools unchanged`,
"warning",
);
dbg?.("tools.enable.error", {
kind: "tools_excluded",
count: missing.length,
});
return;
}
const active = pi.getActiveTools();
displaced = active.filter(
(tool) => tool === "bash" || tool === "powershell",
);
pi.setActiveTools([
...active.filter((tool) => !displaced.includes(tool)),
"nu",
"nu_session",
]);
enabled = true;
dbg?.("tools.enabled", { count: displaced.length });
if (!quiet)
ctx.ui.notify(
"Nushell tools on: nu (fresh), nu_session (persistent)",
"info",
);
}
const freshTool = createBashToolDefinition(process.cwd(), {
operations: {
exec: (command, cwd, options) => {
const binary = findNu();
if (!binary) throw new Error("Nushell executable not found on PATH");
return freshOperations(binary).exec(command, cwd, options);
},
},
});
pi.registerTool({
...freshTool,
name: "nu",
label: " nu",
description:
"Execute Nushell commands in a fresh process in Pi's cwd. Syntax is Nushell, not Bash or PowerShell. Each call has independent variables, functions, environment, and cwd. Streams output; timeout in seconds is optional.",
promptSnippet:
"Execute independent Nushell commands with nu; use nu_session only for state across calls",
promptGuidelines: [
"nu already runs Nushell. Pass source directly; never wrap it in nu -c or ^nu -c. Prefer native commands like ls, pwd, open, mkdir, save; use ^name only for necessary external executables. Nu flags differ from GNU flags: check help <command> before guessing options. Batch short independent checks when readable.",
"Use Nushell syntax: structured pipelines, $env.NAME, (command), o> file or save. Use try/catch for expected command failures.",
],
renderCall: (args, theme, context) =>
renderNuCall(args, theme, context, " nu>"),
});
pi.registerTool({
name: "nu_session",
label: " nu session",
description:
"Evaluate Nushell in a persistent session. Variables, functions, environment, and cwd survive subsequent nu_session calls; nu calls stay independent. Prefer nu unless state must persist. MCP captures expression results as NUON; use ^command | complete and inspect exit_code for external status. Do not rely on print: MCP uses stderr for diagnostics.",
promptSnippet:
"Evaluate persistent Nushell state with nu_session when later calls depend on earlier state",
promptGuidelines: [
"Use nu by default; choose nu_session only for state shared across calls. Both tools already run Nu, so do not wrap commands in nu -c or ^nu -c. Nu session is sequential; /nushell reset clears its state. Cancelled commands can leave detached external processes running.",
],
executionMode: "sequential",
parameters: Type.Object({
command: Type.String({
description: "Nushell source to evaluate in the persistent session",
}),
}),
async execute(_id, { command }, signal, _onUpdate, ctx) {
const binary = findNu();
if (!binary) throw new Error("Nushell executable not found on PATH");
const text = await session.evaluate(binary, ctx.cwd, command, signal);
return { content: [{ type: "text", text }], details: undefined };
},
renderCall: (args, theme, context) =>
renderNuCall(args, theme, context, " nu session>"),
renderResult: freshTool.renderResult,
});
pi.registerCommand("nushell", {
description:
"Toggle Nushell tools and replace active bash/powershell; /nushell reset clears persistent state",
getArgumentCompletions: (prefix) => {
const matches = ["on", "off", "reset"].filter((value) =>
value.startsWith(prefix),
);
return matches.length
? matches.map((value) => ({ value, label: value }))
: null;
},
async handler(arg, ctx) {
const action = arg.trim() || (enabled ? "off" : "on");
if (action === "reset") {
await session.reset("explicit");
ctx.ui.notify("Nushell session reset", "info");
return;
}
if (action === "on") {
activate(ctx);
return;
}
if (action === "off") {
if (!enabled) return;
await session.reset("toggle_off");
const active = pi
.getActiveTools()
.filter((tool) => tool !== "nu" && tool !== "nu_session");
pi.setActiveTools([
...active,
...displaced.filter((tool) => !active.includes(tool)),
]);
dbg?.("tools.disabled", { count: displaced.length });
displaced = [];
enabled = false;
ctx.ui.notify("Nushell tools off; persistent state cleared", "info");
return;
}
ctx.ui.notify("Usage: /nushell [on|off|reset]", "warning");
},
});
pi.on("session_start", async (_event, ctx) => {
await session.reset("session_start");
dbg?.("session.start");
enabled = false;
displaced = [];
pi.setActiveTools(
pi
.getActiveTools()
.filter((name) => name !== "nu" && name !== "nu_session"),
);
const setting = resolveSetting(pi, ctx, ENABLE_SETTING);
if (!setting.ok) {
ctx.ui.notify(`${setting.error}; tools unchanged`, "warning");
dbg?.("settings.invalid", { source: setting.source });
} else if (setting.value) activate(ctx, true);
});
pi.on("session_tree", async () => {
await session.reset("session_tree");
});
pi.on("session_shutdown", async () => {
await session.reset("session_shutdown");
dbg?.("session.shutdown");
closeDebug();
});
}