/** * Run Command Extension * * 两种入口: * - /run-cmd:从 assistant 消息里提取命令,选择后确认执行 * - 代码块上的 [▶ 运行 #n] 按钮(由 @muzi3/code-button 提供点击链路) * * 两者都走同一个 Herdr 执行路径:新开一个 pane 跑命令,输出实时可见。 */ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { execFileSync } from "node:child_process"; import { ensureCodeButtonBase, registerCodeBlockButtons, toast } from "@muzi3/code-button"; import { createFuzzySelector } from "@muzi3/fuzzy-selector"; /** 能挂「运行」按钮的代码块语言 */ const SHELL_LANGUAGES = new Set(["bash", "sh", "shell", "zsh", "console"]); interface ContentBlock { type?: string; text?: string; } interface SessionEntry { type: string; message?: { role?: string; content?: unknown; }; } interface CommandItem { command: string; label: string; /** 供模糊搜索的文本(索引签名是 fuzzy-selector 的要求) */ searchText: string; [key: string]: unknown; } function extractTextFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const part of content) { if (part && typeof part === "object") { const block = part as ContentBlock; if (block.type === "text" && typeof block.text === "string") { parts.push(block.text); } } } return parts.join("\n"); } function extractCommands(text: string): CommandItem[] { const commands: CommandItem[] = []; const seen = new Set(); // 提取 ```bash/sh/shell/zsh 代码块中的命令 const codeBlockRegex = /```(?:bash|sh|shell|zsh)\n([\s\S]*?)```/g; let match: RegExpExecArray | null; while ((match = codeBlockRegex.exec(text)) !== null) { const block = match[1].trim(); // 每个代码块可能包含多条命令(按行分割) const lines = block.split("\n").filter((l) => { const trimmed = l.trim(); // 跳过注释行和空行 return trimmed && !trimmed.startsWith("#"); }); for (const line of lines) { // 去掉可能的 $ 或 > 前缀 const cmd = line.replace(/^\s*[$>]\s*/, "").trim(); if (cmd && !seen.has(cmd)) { seen.add(cmd); const preview = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd; commands.push({ command: cmd, label: preview, searchText: cmd }); } } } // 提取行内 `command` 格式(单反引号中看起来像命令的内容) const inlineRegex = /(?:运行|执行|使用|输入|run|execute|try|use)\s*[::]\s*`([^`]+)`/gi; while ((match = inlineRegex.exec(text)) !== null) { const cmd = match[1].trim(); if (cmd && !seen.has(cmd) && looksLikeCommand(cmd)) { seen.add(cmd); const preview = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd; commands.push({ command: cmd, label: preview, searchText: cmd }); } } return commands; } function looksLikeCommand(text: string): boolean { // 简单启发式:以常见命令开头或包含路径/flag const cmdPatterns = [ /^(npm|npx|yarn|pnpm|bun)\s/, /^(git|docker|kubectl|curl|wget)\s/, /^(cd|ls|cat|rm|cp|mv|mkdir|touch|chmod)\s/, /^(pip|python|node|deno|go|cargo|make)\s/, /^(brew|apt|yum|dnf|pacman)\s/, /^(sudo|ssh|scp|rsync)\s/, /^[./~]/, /\s--?\w/, ]; return cmdPatterns.some((p) => p.test(text)); } /** * 在新的 Herdr pane 里执行命令。 * `herdr pane run` 会把整段命令文本发给 pane 里的 shell 再回车, * 管道 / && / ; 等复合语法由该 shell 解释,不再套一层 bash -c。 * 返回新 pane 的 id。失败时抛出带可读信息的错误。 */ function runInHerdrPane(command: string, cwd: string): string { const herdrPaneId = process.env.HERDR_PANE_ID; if (!herdrPaneId) throw new Error("未在 Herdr 环境中运行,无法创建新 pane"); try { const splitRaw = execFileSync( "herdr", [ "pane", "split", "--pane", herdrPaneId, "--direction", "down", "--cwd", cwd, "--no-focus", ], { encoding: "utf8" }, ); const splitResult = JSON.parse(splitRaw) as { result: { pane: { pane_id: string } }; }; const newPaneId = splitResult.result.pane.pane_id; execFileSync("herdr", ["pane", "run", newPaneId, command], { encoding: "utf8", }); return newPaneId; } catch (err: unknown) { const e = err as { stderr?: string; stdout?: string; message?: string }; const detail = e.stderr?.trim() || e.stdout?.trim() || e.message || "未知错误"; throw new Error(`Herdr 执行失败:${detail.slice(0, 300)}`); } } export default function (pi: ExtensionAPI) { let uiCtx: ExtensionContext | null = null; // 按钮基座(transformer + 本地 HTTP 服务):确保单装 run-cmd 也能渲染按钮 ensureCodeButtonBase(pi); pi.on("session_start", async (_event, ctx) => { uiCtx = ctx; }); // 代码块上的可点击「运行」按钮:只给 shell 块,且必须在 Herdr 里 registerCodeBlockButtons((block) => { if (!process.env.HERDR_PANE_ID) return null; if (!SHELL_LANGUAGES.has(block.language)) return null; return [ { key: `run:${block.index}`, label: `[▶ 运行 #${block.index}]`, run: async () => { const ctx = uiCtx; if (!ctx) return; // 点击可能误触,执行前一律二次确认 const preview = block.code.length > 500 ? `${block.code.slice(0, 500)}\n...` : block.code; const confirmed = await ctx.ui.confirm( `确认执行代码块 #${block.index}`, `将在新 pane 中执行:\n\n${preview}\n\n确认执行?`, ); if (!confirmed) { toast("已取消", "error"); return; } const paneId = runInHerdrPane( block.code, ctx.cwd ?? process.cwd(), ); toast(`✓ 已在 pane ${paneId} 执行 #${block.index}`); ctx.ui.setEditorText(`读取 pane ${paneId} 的执行结果`); }, }, ]; }); pi.registerCommand("run-cmd", { description: "选择并执行 AI 建议的命令", handler: async (_args, ctx) => { const branch = ctx.sessionManager.getBranch(); // 从所有 assistant 消息中提取命令 const allCommands: CommandItem[] = []; for (const entry of branch) { const e = entry as SessionEntry; if (e.type !== "message" || e.message?.role !== "assistant") continue; const text = extractTextFromContent(e.message.content); const cmds = extractCommands(text); allCommands.push(...cmds); } if (allCommands.length === 0) { ctx.ui.notify("当前会话中没有找到可执行命令", "info"); return; } // 反转顺序,最新的在前 allCommands.reverse(); let selected: CommandItem | null = null; if (ctx.mode === "tui") { selected = await ctx.ui.custom( (_tui, theme, keybindings, done) => createFuzzySelector({ items: allCommands, getText: (item) => item.searchText, getLabel: (item) => item.label, title: "run-cmd", initialQuery: _args.trim(), theme, keybindings, done, }), { overlay: true, overlayOptions: { width: "80%", anchor: "center" } }, ); } else { const filter = _args.trim().toLowerCase(); const filtered = filter ? allCommands.filter((item) => item.searchText.toLowerCase().includes(filter), ) : allCommands; if (filtered.length === 0) { ctx.ui.notify(`没有匹配 "${_args.trim()}" 的命令`, "info"); return; } if (filtered.length === 1) { selected = filtered[0]; } else { const labels = filtered.map((item, i) => `${i + 1}. ${item.label}`); const choice = await ctx.ui.select("选择要执行的命令", labels); if (!choice) return; const idx = Number.parseInt(choice.split(".")[0], 10) - 1; if (idx >= 0 && idx < filtered.length) selected = filtered[idx]; } } if (!selected) return; const cmd = selected; // 二次确认 const confirmed = await ctx.ui.confirm( "确认执行", `将执行以下命令:\n\n${cmd.command}\n\n确认执行?`, ); if (!confirmed) { ctx.ui.notify("已取消", "info"); return; } // 通过 Herdr 在新 pane 中执行命令,输出实时可见 try { const newPaneId = runInHerdrPane( cmd.command, ctx.cwd ?? process.cwd(), ); ctx.ui.notify( `✓ 已在新 pane (${newPaneId}) 中执行:\n${cmd.command}`, "info", ); // 在输入框预填读取结果的提示词,方便用户执行完后直接发送 ctx.ui.setEditorText(`读取 pane ${newPaneId} 的执行结果`); } catch (err: unknown) { ctx.ui.notify(`✗ ${(err as Error).message}`, "error"); } }, }); }