actions.ts
1 import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; 2 import { Container, Text, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; 3 import { DynamicBorder } from "@mariozechner/pi-coding-agent"; 4 import * as fs from "node:fs"; 5 import * as os from "node:os"; 6 import * as path from "node:path"; 7 let justBash: { Bash?: any; OverlayFs?: any } | null = null; 8 let justBashLoadPromise: Promise<void> | null = null; 9 let justBashLoadDone = false; 10 11 async function ensureJustBashLoaded(): Promise<void> { 12 if (justBashLoadDone) return; 13 14 if (!justBashLoadPromise) { 15 justBashLoadPromise = import("just-bash") 16 .then((mod: any) => { 17 justBash = mod; 18 }) 19 .catch(() => { 20 justBash = null; 21 }) 22 .finally(() => { 23 justBashLoadDone = true; 24 }); 25 } 26 27 await justBashLoadPromise; 28 } 29 30 export async function copyToClipboard(pi: ExtensionAPI, content: string): Promise<boolean> { 31 const tmpPath = path.join(os.tmpdir(), `pi-code-${Date.now()}.txt`); 32 fs.writeFileSync(tmpPath, content, "utf8"); 33 34 const commands: Array<{ command: string; args: string[] }> = []; 35 if (process.platform === "darwin") { 36 commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | pbcopy`] }); 37 } else if (process.platform === "win32") { 38 commands.push({ command: "powershell", args: ["-NoProfile", "-Command", `Get-Content -Raw "${tmpPath}" | Set-Clipboard`] }); 39 } else { 40 commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | wl-copy`] }); 41 commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xclip -selection clipboard`] }); 42 commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xsel --clipboard --input`] }); 43 } 44 45 let success = false; 46 for (const cmd of commands) { 47 try { 48 const result = await pi.exec(cmd.command, cmd.args); 49 if (result.code === 0) { 50 success = true; 51 break; 52 } 53 } catch { 54 // Try next command 55 } 56 } 57 58 try { 59 fs.unlinkSync(tmpPath); 60 } catch { 61 // Ignore cleanup errors 62 } 63 64 return success; 65 } 66 67 export function insertIntoEditor(ctx: ExtensionCommandContext, content: string): void { 68 const existing = ctx.ui.getEditorText(); 69 const next = existing ? `${existing}\n${content}` : content; 70 ctx.ui.setEditorText(next); 71 } 72 73 function formatOutput(command: string, result: { stdout: string; stderr: string; code: number }): string { 74 const lines: string[] = []; 75 lines.push(`Command: ${command}`); 76 lines.push(`Exit code: ${result.code}`); 77 78 if (result.stdout.trim().length > 0) { 79 lines.push(""); 80 lines.push("STDOUT:"); 81 lines.push(result.stdout.trimEnd()); 82 } 83 84 if (result.stderr.trim().length > 0) { 85 lines.push(""); 86 lines.push("STDERR:"); 87 lines.push(result.stderr.trimEnd()); 88 } 89 90 return lines.join("\n"); 91 } 92 93 function truncateLines(text: string, maxLines: number): string { 94 const lines = text.split(/\r?\n/); 95 if (lines.length <= maxLines) return text; 96 const truncated = lines.slice(0, maxLines).join("\n"); 97 return `${truncated}\n\n[Output truncated to ${maxLines} lines]`; 98 } 99 100 type CommandRunResult = { 101 stdout: string; 102 stderr: string; 103 code: number; 104 commandLabel: string; 105 }; 106 107 function looksLikeMissingCommand(stderr: string): boolean { 108 const normalized = stderr.toLowerCase(); 109 return normalized.includes("command not found") || normalized.includes("unknown command") || normalized.includes("not recognized"); 110 } 111 112 function normalizeShellSnippetForExecution(snippet: string): string { 113 const trimmed = snippet.trim(); 114 115 // If the snippet is a tool-call style JSON object, extract the command field 116 if (trimmed.startsWith("{") && trimmed.endsWith("}")) { 117 try { 118 const parsed = JSON.parse(trimmed) as any; 119 const cmd = typeof parsed?.command === "string" 120 ? parsed.command 121 : typeof parsed?.cmd === "string" 122 ? parsed.cmd 123 : null; 124 if (cmd && cmd.trim().length > 0) return cmd.trim(); 125 } catch { 126 // ignore 127 } 128 } 129 130 const lines = snippet.split(/\r?\n/); 131 const hasPromptLines = lines.some( 132 (line) => /^\s*\$\s+/.test(line) || /^\s*>\s+/.test(line) || /^\s*!\s*/.test(line), 133 ); 134 135 // Common Pi convention: snippets sometimes include a leading `!` to indicate “run in shell” 136 // If there are no prompt-like transcript lines, just strip a single leading `!` 137 if (!hasPromptLines) { 138 return trimmed.startsWith("!") 139 ? trimmed.replace(/^!\s*/, "") 140 : trimmed; 141 } 142 143 const extracted = lines 144 .map((line) => { 145 if (/^\s*\$\s+/.test(line)) return line.replace(/^\s*\$\s+/, ""); 146 if (/^\s*>\s+/.test(line)) return line.replace(/^\s*>\s+/, ""); 147 if (/^\s*!\s*/.test(line)) return line.replace(/^\s*!\s*/, ""); 148 return ""; 149 }) 150 .filter((line) => line.trim().length > 0) 151 .join("\n") 152 .trim(); 153 154 return extracted.length > 0 ? extracted : trimmed; 155 } 156 157 async function runSnippetInSystemShell(pi: ExtensionAPI, ctx: ExtensionCommandContext, snippet: string): Promise<CommandRunResult> { 158 const isWindows = process.platform === "win32"; 159 const command = isWindows ? "powershell" : "bash"; 160 const args = isWindows ? ["-NoProfile", "-Command", snippet] : ["-lc", snippet]; 161 const result = await pi.exec(command, args, { cwd: ctx.cwd }); 162 163 return { 164 stdout: result.stdout, 165 stderr: result.stderr, 166 code: result.code, 167 commandLabel: `${command} ${args.join(" ")}`, 168 }; 169 } 170 171 async function runSnippetInSandbox(snippet: string, cwd: string): Promise<CommandRunResult> { 172 await ensureJustBashLoaded(); 173 const OverlayFsCtor = justBash?.OverlayFs; 174 const BashCtor = justBash?.Bash; 175 if (typeof OverlayFsCtor !== "function" || typeof BashCtor !== "function") { 176 throw new Error("just-bash is not available"); 177 } 178 179 const overlay = new OverlayFsCtor({ root: cwd }); 180 const bash = new BashCtor({ 181 fs: overlay, 182 cwd: overlay.getMountPoint(), 183 executionLimits: { 184 maxCallDepth: 32, 185 maxCommandCount: 1000, 186 maxLoopIterations: 3000, 187 maxAwkIterations: 8000, 188 maxSedIterations: 8000, 189 }, 190 }); 191 192 const result = await bash.exec(snippet); 193 return { 194 stdout: result.stdout, 195 stderr: result.stderr, 196 code: result.exitCode, 197 commandLabel: `just-bash (overlayfs, read-only) -c ${JSON.stringify(snippet)}`, 198 }; 199 } 200 201 export async function runSnippet(pi: ExtensionAPI, ctx: ExtensionCommandContext, snippet: string): Promise<void> { 202 let runResult: CommandRunResult; 203 204 const normalizedSnippet = normalizeShellSnippetForExecution(snippet); 205 206 if (process.platform === "win32") { 207 runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet); 208 } else { 209 let sandboxResult: CommandRunResult | null = null; 210 try { 211 sandboxResult = await runSnippetInSandbox(normalizedSnippet, ctx.cwd); 212 } catch { 213 sandboxResult = null; 214 } 215 216 if (!sandboxResult) { 217 runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet); 218 } else if (sandboxResult.code !== 0 && looksLikeMissingCommand(sandboxResult.stderr)) { 219 const stderrPreview = (sandboxResult.stderr ?? "").trim().slice(0, 500); 220 const proceed = await ctx.ui.confirm( 221 "Sandbox missing command", 222 "The just-bash sandbox could not run this snippet because one or more commands are unsupported.\n\n" + 223 `Snippet:\n${normalizedSnippet}\n\n` + 224 (stderrPreview.length > 0 ? `Sandbox error:\n${stderrPreview}\n\n` : "") + 225 "Run it in your real shell instead?", 226 ); 227 228 if (proceed) { 229 runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet); 230 } else { 231 runResult = sandboxResult; 232 } 233 } else { 234 runResult = sandboxResult; 235 } 236 } 237 238 const output = truncateLines( 239 formatOutput(runResult.commandLabel, { 240 stdout: runResult.stdout, 241 stderr: runResult.stderr, 242 code: runResult.code, 243 }), 244 200, 245 ); 246 247 await ctx.ui.custom<void>((tui, theme, _kb, done) => { 248 const container = new Container(); 249 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 250 container.addChild(new Text(theme.fg("accent", theme.bold("Command Output")), 1, 0)); 251 252 const text = new Text(output, 1, 0); 253 container.addChild(text); 254 255 container.addChild(new Text(theme.fg("dim", "Enter/Esc to close"), 1, 0)); 256 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 257 258 return { 259 render: (width: number) => container.render(width).map((line) => truncateToWidth(line, width)), 260 invalidate: () => container.invalidate(), 261 handleInput: (data: string) => { 262 if (matchesKey(data, "escape") || matchesKey(data, "enter")) { 263 done(); 264 } 265 }, 266 }; 267 }); 268 }