import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; const extensionDir = dirname(fileURLToPath(import.meta.url)); const promptPath = join(extensionDir, "prompt.md"); const renderScriptPath = join(extensionDir, "render-review.mjs"); export default function annotatedReviewExtension(pi: ExtensionAPI) { pi.registerCommand("annotated-review", { description: "Create an annotated HTML review page for git changes", getArgumentCompletions: () => null, handler: async (args, ctx) => { if (!ctx.isIdle()) { ctx.ui.notify("Agent is busy. Run /annotated-review after the current turn finishes.", "warning"); return; } const options = parseReviewArgs(args, ctx.cwd); const prompt = renderPrompt({ args, cwd: ctx.cwd, extensionDir, renderScriptPath, outputPath: options.outputPath, baseRef: options.baseRef, }); pi.sendUserMessage(prompt); }, }); pi.registerCommand("annotated-review-paths", { description: "Show annotated-review extension paths", handler: async (_args, ctx) => { ctx.ui.notify( [ `Extension: ${extensionDir}`, `Prompt: ${promptPath}`, `Renderer: ${renderScriptPath}`, ].join("\n"), "info", ); }, }); } interface PromptVars { args: string; cwd: string; extensionDir: string; renderScriptPath: string; outputPath: string; baseRef?: string; } function renderPrompt(vars: PromptVars): string { const template = readFileSync(promptPath, "utf8"); const baseFlag = vars.baseRef ? `--base ${shellQuote(vars.baseRef)}` : ""; const replacements: Record = { ARGUMENTS: vars.args, CWD: vars.cwd, CWD_QUOTED: shellQuote(vars.cwd), EXTENSION_DIR: vars.extensionDir, RENDER_SCRIPT: vars.renderScriptPath, RENDER_SCRIPT_QUOTED: shellQuote(vars.renderScriptPath), OUTPUT_PATH: vars.outputPath, OUTPUT_PATH_QUOTED: shellQuote(vars.outputPath), BASE_REF: vars.baseRef ?? "", BASE_FLAG: baseFlag, }; return template.replace(/\{\{([A-Z_]+)\}\}/g, (_match, key: string) => replacements[key] ?? ""); } function parseReviewArgs(args: string, cwd: string): { baseRef?: string; outputPath: string } { const tokens = tokenizeArgs(args); let baseRef: string | undefined; let outputPath: string | undefined; for (const token of tokens) { if (token.endsWith(".html")) { outputPath = token; continue; } if (!baseRef) baseRef = token; } const resolvedOutput = outputPath ? resolve(cwd, outputPath) : resolve(cwd, ".pi", "reviews", `annotated-review-${timestampForPath(new Date())}.html`); return { baseRef, outputPath: resolvedOutput }; } function tokenizeArgs(input: string): string[] { const tokens: string[] = []; let current = ""; let quote: '"' | "'" | undefined; let escaping = false; for (const char of input.trim()) { if (escaping) { current += char; escaping = false; continue; } if (char === "\\") { escaping = true; continue; } if (quote) { if (char === quote) quote = undefined; else current += char; continue; } if (char === "'" || char === '"') { quote = char as '"' | "'"; continue; } if (/\s/.test(char)) { if (current) { tokens.push(current); current = ""; } continue; } current += char; } if (current) tokens.push(current); return tokens; } function timestampForPath(date: Date): string { const pad = (value: number) => String(value).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }