From e68d8e7f2e5c275603f77dcf57d3af6ef3cf1c26 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Wed, 23 Sep 2026 10:46:21 +0200 Subject: [PATCH] Initial release of bash code actions extension --- .gitignore | 3 ++ README.md | 33 ++++++++++++++ index.ts | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 +++++++ 4 files changed, 171 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.ts create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aafcb34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..97f57cc --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# pi-bash-code-actions + +A Pi extension that detects fenced `bash`, `sh`, `shell`, and `zsh` blocks in assistant replies and adds actions to the transcript: + +- **Copy** the block to the system clipboard (`wl-copy`, `xclip`, or `xsel` on Linux; `pbcopy` on macOS; `clip` on Windows). +- **Open terminal** with the block loaded as a temporary script. It prefers Konsole, then `$TERMINAL`, GNOME Terminal, and `x-terminal-emulator`. +- **Execute** after showing the code in a Pi confirmation dialog. Execution takes place in the newly opened terminal. + +The `/bash-blocks` command lets you select a detected block and choose an action. Existing session history is scanned when the extension starts. + +## Safety + +Shell code is powerful. **Opening a terminal or executing a block can modify files, install software, access networks, or affect the system.** The Execute action always asks for confirmation; inspect the complete command before approving it. The warning shown for some risky patterns is only advisory and is not a security sandbox. Opening a terminal does not execute without normal shell interpretation of the script. + +The extension only provides UI/actions in interactive Pi sessions. It does not execute code automatically when a block is detected. + +## Install + +```bash +pi install git:git.enne2.net/enne2/pi-bash-code-actions +``` + +Then run `/reload` or restart Pi. The command `/bash-blocks` is available in the session. + +## Requirements + +- Pi 0.87.1 or newer +- A terminal emulator for terminal actions +- A clipboard utility for Copy + +## Development + +The extension entry point is `index.ts`. The local extension API supplies the Pi and TUI dependencies. diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..a0916a4 --- /dev/null +++ b/index.ts @@ -0,0 +1,120 @@ +import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { Box, MouseRegion, Text, type TuiMouseEvent } from "@earendil-works/pi-tui"; + +const TYPE = "bash-code-actions"; +const LANGS = new Set(["bash", "sh", "shell", "zsh"]); +type Block = { index: number; language: string; code: string }; +type Data = { sourceEntryId: string; cwd: string; blocks: Block[] }; +let current: ExtensionContext | undefined; + +function body(message: unknown): string { + const parts = (message as AssistantMessage | undefined)?.content; + return Array.isArray(parts) ? parts.filter(p => p.type === "text").map(p => p.text).join("\n") : ""; +} +function parse(text: string): Block[] { + const out: Block[] = []; + for (const m of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/g)) { + const language = m[1].trim().toLowerCase().split(/\s+/)[0] || ""; + if (LANGS.has(language)) out.push({ index: out.length + 1, language, code: m[2].replace(/\n$/, "") }); + } + return out; +} +function quote(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } +function stdin(command: string, args: string[], text: string): Promise { + return new Promise(resolve => { + const p = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] }); + let settled = false; + const done = (ok: boolean) => { if (!settled) { settled = true; resolve(ok); } }; + p.once("error", () => done(false)); p.once("close", code => done(code === 0)); p.stdin.end(text); + }); +} +async function clipboard(text: string): Promise { + if (process.platform === "darwin") return stdin("pbcopy", [], text); + if (process.platform === "win32") return stdin("clip", [], text); + return await stdin("wl-copy", [], text) || await stdin("xclip", ["-selection", "clipboard"], text) || stdin("xsel", ["--clipboard", "--input"], text); +} +async function terminal(code: string, cwd: string): Promise { + const dir = await fs.mkdtemp(join(tmpdir(), "pi-bash-code-")), script = join(dir, "run.sh"); + await fs.writeFile(script, `#!/usr/bin/env bash\ntrap 'rm -rf -- "$(dirname -- "$0")"' EXIT\ncd -- ${quote(cwd)} 2>/dev/null || true\n${code}\n`, { mode: 0o700 }); + const candidates: Array<[string, string[]]> = []; + const configured = process.env.TERMINAL?.trim(); + if (configured && !configured.includes(" ")) candidates.push([configured, ["bash", script]]); + candidates.push(["konsole", ["--hold", "-e", "bash", script]], ["gnome-terminal", ["--", "bash", script]], ["x-terminal-emulator", ["-e", "bash", script]]); + for (const [cmd, args] of candidates) { + const ok = await new Promise(resolve => { + const p = spawn(cmd, args, { detached: true, stdio: "ignore" }); + p.once("error", () => resolve(false)); p.once("spawn", () => { p.unref(); resolve(true); }); + }); + if (ok) return true; + } + await fs.rm(dir, { recursive: true, force: true }); return false; +} +function warning(code: string): string { + if (/\bsudo\b|\brm\s+-rf\b|\bdd\s+if=|\bmkfs\b|\bshutdown\b|\breboot\b/.test(code)) return "⚠ Comandi privilegiati o potenzialmente distruttivi."; + if (/\bcurl\b|\bwget\b|\|\s*(bash|sh)\b/.test(code)) return "⚠ Comandi di rete o pipe a una shell."; + return ""; +} +async function action(kind: "copy" | "open" | "execute", block: Block, data: Data, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + if (kind === "copy") { + const ok = await clipboard(block.code); ctx.ui.notify(ok ? `Blocco ${block.index} copiato.` : "Clipboard non disponibile.", ok ? "info" : "error"); return; + } + if (kind === "execute") { + const shown = block.code.length > 1600 ? `${block.code.slice(0, 1597)}...` : block.code; + if (!(await ctx.ui.confirm("Conferma esecuzione", [`Eseguire il blocco bash ${block.index}?`, "", shown, warning(block.code)].filter(Boolean).join("\n")))) return; + } + const ok = await terminal(block.code, data.cwd); + ctx.ui.notify(ok ? "Blocco avviato in un nuovo terminale." : "Terminale non disponibile.", ok ? "info" : "error"); +} +function preview(code: string): string { + const line = code.split("\n").find(s => s.trim())?.trim() || "(vuoto)"; return line.length > 65 ? `${line.slice(0, 62)}...` : line; +} +function button(label: string, kind: "copy" | "open" | "execute", block: Block, data: Data, theme: any): MouseRegion { + return new MouseRegion(new Text(theme.fg(kind === "execute" ? "warning" : "accent", `[ ${label} ]`), 1, 0), (e: TuiMouseEvent) => { + if (e.type !== "click" || e.button !== "left") return undefined; + if (current) void action(kind, block, data, current); return { handled: true, render: false }; + }); +} +function render(data: Data, theme: any): Box { + const box = new Box(1, 0, s => theme.bg("customMessageBg", s)); + box.addChild(new Text(theme.fg("muted", "bash code actions"), 1, 0)); + for (const b of data.blocks) { + box.addChild(new Text(theme.fg("dim", `${b.index}. ${preview(b.code)}`), 1, 0)); + box.addChild(button("copia", "copy", b, data, theme)); box.addChild(button("terminale", "open", b, data, theme)); box.addChild(button("esegui", "execute", b, data, theme)); + } + return box; +} +function known(ctx: ExtensionContext): Data[] { + return ctx.sessionManager.getBranch().filter((e: any) => e.type === "custom" && e.customType === TYPE).map((e: any) => e.data).filter((d: any) => d?.blocks); +} +async function recover(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + const branch = ctx.sessionManager.getBranch() as any[]; + const ids = new Set(branch.filter(e => e.type === "custom" && e.customType === TYPE).map(e => e.data?.sourceEntryId)); + for (const e of branch) { + if (e.type !== "message" || e.message?.role !== "assistant" || ids.has(e.id)) continue; + const blocks = parse(body(e.message)); if (blocks.length) pi.appendEntry(TYPE, { sourceEntryId: e.id, cwd: ctx.cwd, blocks } satisfies Data); + } +} +export default function (pi: ExtensionAPI) { + pi.registerEntryRenderer(TYPE, (entry, _opts, theme) => { const d = entry.data as Data | undefined; return d?.blocks?.length ? render(d, theme) : undefined; }); + pi.on("session_start", async (_e, ctx) => { current = ctx; await recover(pi, ctx); }); + pi.on("session_shutdown", () => { current = undefined; }); + pi.on("turn_end", (e, ctx) => { + current = ctx; if (e.message.role !== "assistant") return; + const blocks = parse(body(e.message)); if (blocks.length) pi.appendEntry(TYPE, { sourceEntryId: e.messageEntryId, cwd: ctx.cwd, blocks } satisfies Data); + }); + pi.registerCommand("bash-blocks", { description: "Gestisce i blocchi shell della sessione", handler: async (_args, ctx) => { + const choices = known(ctx).flatMap(d => d.blocks.map(b => ({ d, b, label: `${d.sourceEntryId.slice(0, 8)} · ${b.index}: ${preview(b.code)}` }))); + if (!choices.length) { ctx.ui.notify("Nessun blocco shell trovato.", "info"); return; } + const selected = await ctx.ui.select("Scegli blocco", choices.map(c => c.label)), choice = choices.find(c => c.label === selected); if (!choice) return; + const selectedAction = await ctx.ui.select("Azione", ["Copia", "Apri terminale", "Esegui"]); + if (selectedAction === "Copia") await action("copy", choice.b, choice.d, ctx); + if (selectedAction === "Apri terminale") await action("open", choice.b, choice.d, ctx); + if (selectedAction === "Esegui") await action("execute", choice.b, choice.d, ctx); + } }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..03cd51c --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "pi-bash-code-actions", + "version": "1.0.0", + "description": "Clickable copy and terminal actions for shell code blocks in Pi conversations", + "type": "module", + "keywords": ["pi-package", "pi-extension", "bash", "shell", "terminal"], + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=0.87.1", + "@earendil-works/pi-ai": ">=0.87.1", + "@earendil-works/pi-tui": ">=0.87.1" + }, + "pi": { + "extensions": ["./index.ts"] + } +}