/** * pi-hex — hex viewer / binary file inspector. * /hex file.bin → hex dump (first 512 bytes) * /hex file.bin 0x100 256 → offset + length * /hex file.bin --strings → extract printable strings * /hex file.bin --info → file analysis (magic bytes, entropy) * * Carmack: binary is the ground truth. Learn to read it. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { statSync, existsSync, openSync, readSync, closeSync } from "node:fs"; import { resolve, extname } from "node:path"; const RST = "\x1b[0m", B = "\x1b[1m", D = "\x1b[2m"; const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", CYAN = "\x1b[36m", RED = "\x1b[31m", MAGENTA = "\x1b[35m"; // Magic bytes database const MAGIC: [number[], string][] = [ [[0x89, 0x50, 0x4E, 0x47], "PNG image"], [[0xFF, 0xD8, 0xFF], "JPEG image"], [[0x47, 0x49, 0x46, 0x38], "GIF image"], [[0x25, 0x50, 0x44, 0x46], "PDF document"], [[0x50, 0x4B, 0x03, 0x04], "ZIP archive (or docx/xlsx/jar)"], [[0x1F, 0x8B], "Gzip compressed"], [[0x42, 0x5A, 0x68], "Bzip2 compressed"], [[0xFD, 0x37, 0x7A, 0x58, 0x5A], "XZ compressed"], [[0x7F, 0x45, 0x4C, 0x46], "ELF executable (Linux)"], [[0x4D, 0x5A], "PE executable (Windows)"], [[0xCF, 0xFA, 0xED, 0xFE], "Mach-O (macOS, 64-bit)"], [[0xCE, 0xFA, 0xED, 0xFE], "Mach-O (macOS, 32-bit)"], [[0x52, 0x49, 0x46, 0x46], "RIFF (WAV/AVI/WebP)"], [[0x49, 0x44, 0x33], "MP3 (ID3 tag)"], [[0xFF, 0xFB], "MP3 (sync frame)"], [[0x66, 0x4C, 0x61, 0x43], "FLAC audio"], [[0x4F, 0x67, 0x67, 0x53], "OGG container"], [[0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70], "MP4/M4A video"], [[0x1A, 0x45, 0xDF, 0xA3], "MKV/WebM video"], [[0x53, 0x51, 0x4C, 0x69, 0x74, 0x65], "SQLite database"], [[0x7B], "JSON (probably)"], [[0xEF, 0xBB, 0xBF], "UTF-8 BOM"], [[0xFF, 0xFE], "UTF-16 LE BOM"], [[0xFE, 0xFF], "UTF-16 BE BOM"], ]; function detectMagic(buf: Buffer): string { for (const [bytes, name] of MAGIC) { if (bytes.every((b, i) => i < buf.length && buf[i] === b)) return name; } return "Unknown"; } function entropy(buf: Buffer): number { const freq = new Array(256).fill(0); for (let i = 0; i < buf.length; i++) freq[buf[i]]++; let ent = 0; for (let i = 0; i < 256; i++) { if (freq[i] === 0) continue; const p = freq[i] / buf.length; ent -= p * Math.log2(p); } return ent; } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } function hexDump(buf: Buffer, offset: number, length: number): string { const lines: string[] = []; const end = Math.min(offset + length, buf.length); for (let i = offset; i < end; i += 16) { const addr = `${CYAN}${i.toString(16).padStart(8, "0")}${RST}`; let hex = ""; let ascii = ""; for (let j = 0; j < 16; j++) { if (j === 8) hex += " "; if (i + j < end) { const b = buf[i + j]; // Color: 00=dim, printable=green, high=yellow, control=red const color = b === 0 ? D : (b >= 0x20 && b <= 0x7E) ? GREEN : b >= 0x80 ? YELLOW : RED; hex += `${color}${b.toString(16).padStart(2, "0")}${RST} `; ascii += (b >= 0x20 && b <= 0x7E) ? `${GREEN}${String.fromCharCode(b)}${RST}` : `${D}.${RST}`; } else { hex += " "; ascii += " "; } } lines.push(` ${addr} ${hex} ${D}│${RST}${ascii}${D}│${RST}`); } return lines.join("\n"); } function extractStrings(buf: Buffer, minLen = 4): string[] { const strings: string[] = []; let current = ""; let offset = 0; for (let i = 0; i < buf.length; i++) { const b = buf[i]; if (b >= 0x20 && b <= 0x7E) { if (current.length === 0) offset = i; current += String.fromCharCode(b); } else { if (current.length >= minLen) strings.push(`${CYAN}0x${offset.toString(16).padStart(6, "0")}${RST}: ${GREEN}${current}${RST}`); current = ""; } } if (current.length >= minLen) strings.push(`${CYAN}0x${offset.toString(16).padStart(6, "0")}${RST}: ${GREEN}${current}${RST}`); return strings; } export default function piHex(pi: ExtensionAPI) { pi.registerCommand("hex", { description: "Hex viewer. /hex file [offset] [length] | /hex file --strings | /hex file --info", handler: async (args, ctx) => { const parts = args.trim().split(/\s+/); const flags = parts.filter(p => p.startsWith("--")); const clean = parts.filter(p => !p.startsWith("--")); if (clean.length === 0) { ctx.ui.notify("Usage: /hex file [offset] [length] | /hex file --strings | /hex file --info", "info"); return; } const filePath = resolve(clean[0]); if (!existsSync(filePath)) { ctx.ui.notify(`Not found: ${clean[0]}`, "error"); return; } const stat = statSync(filePath); const readSize = Math.min(stat.size, 1024 * 1024); // Max 1MB const buf = Buffer.alloc(readSize); const fd = openSync(filePath, "r"); readSync(fd, buf, 0, readSize, 0); closeSync(fd); if (flags.includes("--info")) { const magic = detectMagic(buf); const ent = entropy(buf.slice(0, Math.min(buf.length, 65536))); const isText = ent < 5.0; const entColor = ent > 7.5 ? RED : ent > 5.0 ? YELLOW : GREEN; const entLabel = ent > 7.5 ? "encrypted/compressed" : ent > 5.0 ? "binary" : "text/structured"; ctx.ui.notify([ `${B}📊 File Analysis: ${clean[0]}${RST}`, ` ${D}Size:${RST} ${formatSize(stat.size)}`, ` ${D}Type:${RST} ${magic}`, ` ${D}Ext:${RST} ${extname(filePath) || "(none)"}`, ` ${D}Entropy:${RST} ${entColor}${ent.toFixed(3)}/8.000${RST} (${entLabel})`, ` ${D}Text:${RST} ${isText ? `${GREEN}likely text${RST}` : `${YELLOW}binary${RST}`}`, "", ` ${D}First 16 bytes:${RST}`, hexDump(buf, 0, 16), ].join("\n"), "info"); return; } if (flags.includes("--strings")) { const strings = extractStrings(buf); if (strings.length === 0) { ctx.ui.notify("No printable strings found.", "info"); return; } const display = strings.slice(0, 100); ctx.ui.notify(`${B}Strings in ${clean[0]}${RST} (${strings.length} found)\n\n${display.join("\n")}${strings.length > 100 ? `\n${D}... ${strings.length - 100} more${RST}` : ""}`, "info"); return; } // Hex dump const offset = clean[1] ? parseInt(clean[1], clean[1].startsWith("0x") ? 16 : 10) : 0; const length = clean[2] ? parseInt(clean[2]) : 512; const header = `${B}${clean[0]}${RST} ${D}(${formatSize(stat.size)}, showing ${formatSize(Math.min(length, buf.length - offset))} at 0x${offset.toString(16)})${RST}`; ctx.ui.notify(`${header}\n\n ${D}${"OFFSET".padEnd(10)} ${"HEXADECIMAL".padEnd(50)} ASCII${RST}\n${hexDump(buf, offset, length)}`, "info"); }, }); }