chore: drop non-code junk (pi-mono-reference, runtime state, root duplicates)

Remove files that should never have been tracked:

- pi-mono-reference/: 688-file copy of an upstream monorepo, zero imports,
  used only as offline human reference
- build/electron-builder.runtime.json: environment-specific build artifact
  with hardcoded local paths
- state/cancel-signal-state.json: runtime state written by the app
- prompt.txt, logo.svg (root): exact duplicates of the canonical copies
  under .planning/archive/ and assets/

Add state/ to .gitignore so future runtime state stays out.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 22:37:39 -04:00
1 parent 34ca5b77f1
commit 1f7230fe87
691 files changed
+3 -190434

No files matched your search

@@ -1,99 +0,0 @@
import { existsSync, readFileSync } from "fs";
export interface ChangelogEntry {
major: number;
minor: number;
patch: number;
content: string;
}
/**
* Parse changelog entries from CHANGELOG.md
* Scans for ## lines and collects content until next ## or EOF
*/
export function parseChangelog(changelogPath: string): ChangelogEntry[] {
if (!existsSync(changelogPath)) {
return [];
}
try {
const content = readFileSync(changelogPath, "utf-8");
const lines = content.split("\n");
const entries: ChangelogEntry[] = [];
let currentLines: string[] = [];
let currentVersion: { major: number; minor: number; patch: number } | null = null;
for (const line of lines) {
// Check if this is a version header (## [x.y.z] ...)
if (line.startsWith("## ")) {
// Save previous entry if exists
if (currentVersion && currentLines.length > 0) {
entries.push({
...currentVersion,
content: currentLines.join("\n").trim(),
});
}
// Try to parse version from this line
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
if (versionMatch) {
currentVersion = {
major: Number.parseInt(versionMatch[1], 10),
minor: Number.parseInt(versionMatch[2], 10),
patch: Number.parseInt(versionMatch[3], 10),
};
currentLines = [line];
} else {
// Reset if we can't parse version
currentVersion = null;
currentLines = [];
}
} else if (currentVersion) {
// Collect lines for current version
currentLines.push(line);
}
}
// Save last entry
if (currentVersion && currentLines.length > 0) {
entries.push({
...currentVersion,
content: currentLines.join("\n").trim(),
});
}
return entries;
} catch (error) {
console.error(`Warning: Could not parse changelog: ${error}`);
return [];
}
}
/**
* Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
*/
export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number {
if (v1.major !== v2.major) return v1.major - v2.major;
if (v1.minor !== v2.minor) return v1.minor - v2.minor;
return v1.patch - v2.patch;
}
/**
* Get entries newer than lastVersion
*/
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
// Parse lastVersion
const parts = lastVersion.split(".").map(Number);
const last: ChangelogEntry = {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
content: "",
};
return entries.filter((entry) => compareVersions(entry, last) > 0);
}
// Re-export getChangelogPath from paths.ts for convenience
export { getChangelogPath } from "../config.js";
@@ -1,86 +0,0 @@
import type { ChildProcess } from "node:child_process";
const EXIT_STDIO_GRACE_MS = 100;
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* On Windows, daemonized descendants can inherit the child's stdout/stderr pipe
* handles. In that case the child emits `exit`, but `close` can hang forever even
* though the original process is already gone. We wait briefly for stdio to end,
* then forcibly stop tracking the inherited handles.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: NodeJS.Timeout | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
postExitTimer = undefined;
}
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
};
const finalize = (code: number | null) => {
if (settled) return;
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolve(code);
};
const maybeFinalizeAfterExit = () => {
if (!exited || settled) return;
if (stdoutEnded && stderrEnded) {
finalize(exitCode);
}
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onExit = (code: number | null) => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
}
};
const onClose = (code: number | null) => {
finalize(code);
};
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
});
}
@@ -1,300 +0,0 @@
import { spawnSync } from "child_process";
import { randomUUID } from "crypto";
import { readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { clipboard } from "./clipboard-native.js";
import { loadPhoton } from "./photon.js";
export type ClipboardImage = {
bytes: Uint8Array;
mimeType: string;
};
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
const DEFAULT_LIST_TIMEOUT_MS = 1000;
const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
}
function baseMimeType(mimeType: string): string {
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
}
export function extensionForImageMimeType(mimeType: string): string | null {
switch (baseMimeType(mimeType)) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
default:
return null;
}
}
function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
const normalized = mimeTypes
.map((t) => t.trim())
.filter(Boolean)
.map((t) => ({ raw: t, base: baseMimeType(t) }));
for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) {
const match = normalized.find((t) => t.base === preferred);
if (match) {
return match.raw;
}
}
const anyImage = normalized.find((t) => t.base.startsWith("image/"));
return anyImage?.raw ?? null;
}
function isSupportedImageMimeType(mimeType: string): boolean {
const base = baseMimeType(mimeType);
return SUPPORTED_IMAGE_MIME_TYPES.some((t) => t === base);
}
/**
* Convert unsupported image formats to PNG using Photon.
* Returns null if conversion is unavailable or fails.
*/
async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
const photon = await loadPhoton();
if (!photon) {
return null;
}
try {
const image = photon.PhotonImage.new_from_byteslice(bytes);
try {
return image.get_bytes();
} finally {
image.free();
}
} catch {
return null;
}
}
function runCommand(
command: string,
args: string[],
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
): { stdout: Buffer; ok: boolean } {
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
const result = spawnSync(command, args, {
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
env: options?.env,
});
if (result.error) {
return { ok: false, stdout: Buffer.alloc(0) };
}
if (result.status !== 0) {
return { ok: false, stdout: Buffer.alloc(0) };
}
const stdout = Buffer.isBuffer(result.stdout)
? result.stdout
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
return { ok: true, stdout };
}
function readClipboardImageViaWlPaste(): ClipboardImage | null {
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!list.ok) {
return null;
}
const types = list.stdout
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
const selectedType = selectPreferredImageMimeType(types);
if (!selectedType) {
return null;
}
const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (!data.ok || data.stdout.length === 0) {
return null;
}
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}
/**
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
* directly, so we use it as a fallback.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!winPathResult.ok) {
return null;
}
const winPath = winPathResult.stdout.toString("utf-8").trim();
if (!winPath) {
return null;
}
const psScript = [
"Add-Type -AssemblyName System.Windows.Forms",
"Add-Type -AssemblyName System.Drawing",
"$path = $env:PI_WSL_CLIPBOARD_IMAGE_PATH",
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join("; ");
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
env: { ...process.env, PI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
});
if (!result.ok) {
return null;
}
const output = result.stdout.toString("utf-8").trim();
if (output !== "ok") {
return null;
}
const bytes = readFileSync(tmpFile);
if (bytes.length === 0) {
return null;
}
return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
} catch {
return null;
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors.
}
}
}
function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
let candidateTypes: string[] = [];
if (targets.ok) {
candidateTypes = targets.stdout
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
}
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES];
for (const mimeType of tryTypes) {
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
if (data.ok && data.stdout.length > 0) {
return { bytes: data.stdout, mimeType: baseMimeType(mimeType) };
}
}
return null;
}
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
if (!clipboard || !clipboard.hasImage()) {
return null;
}
const imageData = await clipboard.getImageBinary();
if (!imageData || imageData.length === 0) {
return null;
}
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
return { bytes, mimeType: "image/png" };
}
export async function readClipboardImage(options?: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): Promise<ClipboardImage | null> {
const env = options?.env ?? process.env;
const platform = options?.platform ?? process.platform;
if (env.TERMUX_VERSION) {
return null;
}
let image: ClipboardImage | null = null;
if (platform === "linux") {
const wsl = isWSL(env);
const wayland = isWaylandSession(env);
if (wayland || wsl) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (!image && wsl) {
image = readClipboardImageViaPowerShell();
}
if (!image && !wayland) {
image = await readClipboardImageViaNativeClipboard();
}
} else {
image = await readClipboardImageViaNativeClipboard();
}
if (!image) {
return null;
}
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
if (!isSupportedImageMimeType(image.mimeType)) {
const pngBytes = await convertToPng(image.bytes);
if (!pngBytes) {
return null;
}
return { bytes: pngBytes, mimeType: "image/png" };
}
return image;
}
@@ -1,22 +0,0 @@
import { createRequire } from "module";
export type ClipboardModule = {
setText: (text: string) => Promise<void>;
hasImage: () => boolean;
getImageBinary: () => Promise<Array<number>>;
};
const require = createRequire(import.meta.url);
let clipboard: ClipboardModule | null = null;
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
if (!process.env.TERMUX_VERSION && hasDisplay) {
try {
clipboard = require("@mariozechner/clipboard") as ClipboardModule;
} catch {
clipboard = null;
}
}
export { clipboard };
@@ -1,81 +0,0 @@
import { execSync, spawn } from "child_process";
import { platform } from "os";
import { isWaylandSession } from "./clipboard-image.js";
import { clipboard } from "./clipboard-native.js";
type NativeClipboardExecOptions = {
input: string;
timeout: number;
stdio: ["pipe", "ignore", "ignore"];
};
function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
try {
execSync("xclip -selection clipboard", options);
} catch {
execSync("xsel --clipboard --input", options);
}
}
export async function copyToClipboard(text: string): Promise<void> {
// Always emit OSC 52 - works over SSH/mosh, harmless locally
const encoded = Buffer.from(text).toString("base64");
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
try {
if (clipboard) {
await clipboard.setText(text);
return;
}
} catch {
// Fall through to platform-specific clipboard tools.
}
// Also try native tools (best effort for local sessions)
const p = platform();
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
try {
if (p === "darwin") {
execSync("pbcopy", options);
} else if (p === "win32") {
execSync("clip", options);
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
return;
} catch {
// Fall back to Wayland or X11 tools.
}
}
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
proc.unref();
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
}
}
} catch {
// Ignore - OSC 52 already emitted as fallback
}
}
@@ -1,183 +0,0 @@
import type { PhotonImageType } from "./photon.js";
type Photon = typeof import("@silvia-odwyer/photon-node");
function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number {
if (tiffStart + 8 > bytes.length) return 1;
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
const le = byteOrder === 0x4949;
const read16 = (pos: number): number => {
if (le) return bytes[pos] | (bytes[pos + 1] << 8);
return (bytes[pos] << 8) | bytes[pos + 1];
};
const read32 = (pos: number): number => {
if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
};
const ifdOffset = read32(tiffStart + 4);
const ifdStart = tiffStart + ifdOffset;
if (ifdStart + 2 > bytes.length) return 1;
const entryCount = read16(ifdStart);
for (let i = 0; i < entryCount; i++) {
const entryPos = ifdStart + 2 + i * 12;
if (entryPos + 12 > bytes.length) return 1;
if (read16(entryPos) === 0x0112) {
const value = read16(entryPos + 8);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
function findJpegTiffOffset(bytes: Uint8Array): number {
let offset = 2;
while (offset < bytes.length - 1) {
if (bytes[offset] !== 0xff) return -1;
const marker = bytes[offset + 1];
if (marker === 0xff) {
offset++;
continue;
}
if (marker === 0xe1) {
if (offset + 4 >= bytes.length) return -1;
const segmentStart = offset + 4;
if (segmentStart + 6 > bytes.length) return -1;
if (!hasExifHeader(bytes, segmentStart)) return -1;
return segmentStart + 6;
}
if (offset + 4 > bytes.length) return -1;
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
offset += 2 + length;
}
return -1;
}
function findWebpTiffOffset(bytes: Uint8Array): number {
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
const chunkSize =
bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
const dataStart = offset + 8;
if (chunkId === "EXIF") {
if (dataStart + chunkSize > bytes.length) return -1;
// Some WebP files have "Exif\0\0" prefix before the TIFF header
const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
return tiffStart;
}
// RIFF chunks are padded to even size
offset = dataStart + chunkSize + (chunkSize % 2);
}
return -1;
}
function hasExifHeader(bytes: Uint8Array, offset: number): boolean {
return (
bytes[offset] === 0x45 &&
bytes[offset + 1] === 0x78 &&
bytes[offset + 2] === 0x69 &&
bytes[offset + 3] === 0x66 &&
bytes[offset + 4] === 0x00 &&
bytes[offset + 5] === 0x00
);
}
function getExifOrientation(bytes: Uint8Array): number {
let tiffOffset = -1;
// JPEG: starts with FF D8
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
tiffOffset = findJpegTiffOffset(bytes);
}
// WebP: starts with RIFF....WEBP
else if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
tiffOffset = findWebpTiffOffset(bytes);
}
if (tiffOffset === -1) return 1;
return readOrientationFromTiff(bytes, tiffOffset);
}
type DstIndexFn = (x: number, y: number, w: number, h: number) => number;
function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType {
const w = image.get_width();
const h = image.get_height();
const src = image.get_raw_pixels();
const dst = new Uint8Array(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const srcIdx = (y * w + x) * 4;
const dstIdx = dstIndex(x, y, w, h) * 4;
dst[dstIdx] = src[srcIdx];
dst[dstIdx + 1] = src[srcIdx + 1];
dst[dstIdx + 2] = src[srcIdx + 2];
dst[dstIdx + 3] = src[srcIdx + 3];
}
}
return new photon.PhotonImage(dst, h, w);
}
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
export function applyExifOrientation(
photon: Photon,
image: PhotonImageType,
originalBytes: Uint8Array,
): PhotonImageType {
const orientation = getExifOrientation(originalBytes);
if (orientation === 1) return image;
switch (orientation) {
case 2:
photon.fliph(image);
return image;
case 3:
photon.fliph(image);
photon.flipv(image);
return image;
case 4:
photon.flipv(image);
return image;
case 5: {
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
photon.fliph(rotated);
return rotated;
}
case 6:
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
case 7: {
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
photon.fliph(rotated);
return rotated;
}
case 8:
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
default:
return image;
}
}
@@ -1,39 +0,0 @@
import { parse } from "yaml";
type ParsedFrontmatter<T extends Record<string, unknown>> = {
frontmatter: T;
body: string;
};
const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
const normalized = normalizeNewlines(content);
if (!normalized.startsWith("---")) {
return { yamlString: null, body: normalized };
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return { yamlString: null, body: normalized };
}
return {
yamlString: normalized.slice(4, endIndex),
body: normalized.slice(endIndex + 4).trim(),
};
};
export const parseFrontmatter = <T extends Record<string, unknown> = Record<string, unknown>>(
content: string,
): ParsedFrontmatter<T> => {
const { yamlString, body } = extractFrontmatter(content);
if (!yamlString) {
return { frontmatter: {} as T, body };
}
const parsed = parse(yamlString);
return { frontmatter: (parsed ?? {}) as T, body };
};
export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body;
@@ -1,192 +0,0 @@
import hostedGitInfo from "hosted-git-info";
/**
* Parsed git URL information.
*/
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Clone URL (always valid for git clone, without ref suffix) */
repo: string;
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
/** Git ref (branch, tag, commit) if specified */
ref?: string;
/** True if ref was specified (package won't be auto-updated) */
pinned: boolean;
};
function splitRef(url: string): { repo: string; ref?: string } {
const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
const pathWithMaybeRef = scpLikeMatch[2] ?? "";
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
return {
repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`,
ref,
};
}
if (url.includes("://")) {
try {
const parsed = new URL(url);
const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, "");
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
parsed.pathname = `/${repoPath}`;
return {
repo: parsed.toString().replace(/\/$/, ""),
ref,
};
} catch {
return { repo: url };
}
}
const slashIndex = url.indexOf("/");
if (slashIndex < 0) {
return { repo: url };
}
const host = url.slice(0, slashIndex);
const pathWithMaybeRef = url.slice(slashIndex + 1);
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) {
return { repo: url };
}
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) {
return { repo: url };
}
return {
repo: `${host}/${repoPath}`,
ref,
};
}
function parseGenericGitUrl(url: string): GitSource | null {
const { repo: repoWithoutRef, ref } = splitRef(url);
let repo = repoWithoutRef;
let host = "";
let path = "";
const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
host = scpLikeMatch[1] ?? "";
path = scpLikeMatch[2] ?? "";
} else if (
repoWithoutRef.startsWith("https://") ||
repoWithoutRef.startsWith("http://") ||
repoWithoutRef.startsWith("ssh://") ||
repoWithoutRef.startsWith("git://")
) {
try {
const parsed = new URL(repoWithoutRef);
host = parsed.hostname;
path = parsed.pathname.replace(/^\/+/, "");
} catch {
return null;
}
} else {
const slashIndex = repoWithoutRef.indexOf("/");
if (slashIndex < 0) {
return null;
}
host = repoWithoutRef.slice(0, slashIndex);
path = repoWithoutRef.slice(slashIndex + 1);
if (!host.includes(".") && host !== "localhost") {
return null;
}
repo = `https://${repoWithoutRef}`;
}
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "");
if (!host || !normalizedPath || normalizedPath.split("/").length < 2) {
return null;
}
return {
type: "git",
repo,
host,
path: normalizedPath,
ref,
pinned: Boolean(ref),
};
}
/**
* Parse git source into a GitSource.
*
* Rules:
* - With git: prefix, accept all historical shorthand forms.
* - Without git: prefix, only accept explicit protocol URLs.
*/
export function parseGitUrl(source: string): GitSource | null {
const trimmed = source.trim();
const hasGitPrefix = trimmed.startsWith("git:");
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
return null;
}
const split = splitRef(url);
const hostedCandidates = [split.ref ? `${split.repo}#${split.ref}` : undefined, url].filter(
(value): value is string => Boolean(value),
);
for (const candidate of hostedCandidates) {
const info = hostedGitInfo.fromUrl(candidate);
if (info) {
if (split.ref && info.project?.includes("@")) {
continue;
}
const useHttpsPrefix =
!split.repo.startsWith("http://") &&
!split.repo.startsWith("https://") &&
!split.repo.startsWith("ssh://") &&
!split.repo.startsWith("git://") &&
!split.repo.startsWith("git@");
return {
type: "git",
repo: useHttpsPrefix ? `https://${split.repo}` : split.repo,
host: info.domain || "",
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
ref: info.committish || split.ref || undefined,
pinned: Boolean(info.committish || split.ref),
};
}
}
const httpsCandidates = [split.ref ? `https://${split.repo}#${split.ref}` : undefined, `https://${url}`].filter(
(value): value is string => Boolean(value),
);
for (const candidate of httpsCandidates) {
const info = hostedGitInfo.fromUrl(candidate);
if (info) {
if (split.ref && info.project?.includes("@")) {
continue;
}
return {
type: "git",
repo: `https://${split.repo}`,
host: info.domain || "",
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
ref: info.committish || split.ref || undefined,
pinned: Boolean(info.committish || split.ref),
};
}
}
return parseGenericGitUrl(url);
}
@@ -1,41 +0,0 @@
import { applyExifOrientation } from "./exif-orientation.js";
import { loadPhoton } from "./photon.js";
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
*/
export async function convertToPng(
base64Data: string,
mimeType: string,
): Promise<{ data: string; mimeType: string } | null> {
// Already PNG, no conversion needed
if (mimeType === "image/png") {
return { data: base64Data, mimeType };
}
const photon = await loadPhoton();
if (!photon) {
// Photon not available, can't convert
return null;
}
try {
const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
const image = applyExifOrientation(photon, rawImage, bytes);
if (image !== rawImage) rawImage.free();
try {
const pngBuffer = image.get_bytes();
return {
data: Buffer.from(pngBuffer).toString("base64"),
mimeType: "image/png",
};
} finally {
image.free();
}
} catch {
// Conversion failed
return null;
}
}
@@ -1,176 +0,0 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import { applyExifOrientation } from "./exif-orientation.js";
import { loadPhoton } from "./photon.js";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
maxHeight?: number; // Default: 2000
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
jpegQuality?: number; // Default: 80
}
export interface ResizedImage {
data: string; // base64
mimeType: string;
originalWidth: number;
originalHeight: number;
width: number;
height: number;
wasResized: boolean;
}
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
};
interface EncodedCandidate {
data: string;
encodedSize: number;
mimeType: string;
}
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
const data = Buffer.from(buffer).toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
mimeType,
};
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try both PNG and JPEG formats, pick the smaller one
* 3. If still too large, try JPEG with decreasing quality
* 4. If still too large, progressively reduce dimensions until 1x1
*/
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBuffer = Buffer.from(img.data, "base64");
const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
try {
const inputBytes = new Uint8Array(inputBuffer);
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage) rawImage.free();
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const format = img.mimeType?.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
return {
data: img.data,
mimeType: img.mimeType ?? `image/${format}`,
originalWidth,
originalHeight,
width: originalWidth,
height: originalHeight,
wasResized: false,
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
}
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
try {
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
} finally {
resized.free();
}
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
} catch {
return null;
} finally {
if (image) {
image.free();
}
}
}
/**
* Format a dimension note for resized images.
* This helps the model understand the coordinate mapping.
*/
export function formatDimensionNote(result: ResizedImage): string | undefined {
if (!result.wasResized) {
return undefined;
}
const scale = result.originalWidth / result.width;
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
}
@@ -1,30 +0,0 @@
import { open } from "node:fs/promises";
import { fileTypeFromBuffer } from "file-type";
const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const FILE_TYPE_SNIFF_BYTES = 4100;
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
const fileHandle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
if (bytesRead === 0) {
return null;
}
const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead));
if (!fileType) {
return null;
}
if (!IMAGE_MIME_TYPES.has(fileType.mime)) {
return null;
}
return fileType.mime;
} finally {
await fileHandle.close();
}
}
@@ -1,20 +0,0 @@
/**
* Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a URL protocol. Bare names and relative paths without ./ prefix
* are considered local.
*/
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
// Known non-local prefixes
if (
trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") ||
trimmed.startsWith("github:") ||
trimmed.startsWith("http:") ||
trimmed.startsWith("https:") ||
trimmed.startsWith("ssh:")
) {
return false;
}
return true;
}
@@ -1,139 +0,0 @@
/**
* Photon image processing wrapper.
*
* This module provides a unified interface to @silvia-odwyer/photon-node that works in:
* 1. Node.js (development, npm run build)
* 2. Bun compiled binaries (standalone distribution)
*
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
* which bakes the build machine's absolute path into Bun compiled binaries.
*
* Solution:
* 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
* 2. Copy photon_rs_bg.wasm next to the executable in build:binary
*/
import type { PathOrFileDescriptor } from "fs";
import { createRequire } from "module";
import * as path from "path";
import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
const fs = require("fs") as typeof import("fs");
// Re-export types from the main package
export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";
type ReadFileSync = typeof fs.readFileSync;
const WASM_FILENAME = "photon_rs_bg.wasm";
// Lazy-loaded photon module
let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null;
let loadPromise: Promise<typeof import("@silvia-odwyer/photon-node") | null> | null = null;
function pathOrNull(file: PathOrFileDescriptor): string | null {
if (typeof file === "string") {
return file;
}
if (file instanceof URL) {
return fileURLToPath(file);
}
return null;
}
function getFallbackWasmPaths(): string[] {
const execDir = path.dirname(process.execPath);
return [
path.join(execDir, WASM_FILENAME),
path.join(execDir, "photon", WASM_FILENAME),
path.join(process.cwd(), WASM_FILENAME),
];
}
function patchPhotonWasmRead(): () => void {
const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs);
const fallbackPaths = getFallbackWasmPaths();
const mutableFs = fs as { readFileSync: ReadFileSync };
const patchedReadFileSync: ReadFileSync = ((...args: Parameters<ReadFileSync>) => {
const [file, options] = args;
const resolvedPath = pathOrNull(file);
if (resolvedPath?.endsWith(WASM_FILENAME)) {
try {
return originalReadFileSync(...args);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err?.code && err.code !== "ENOENT") {
throw error;
}
for (const fallbackPath of fallbackPaths) {
if (!fs.existsSync(fallbackPath)) {
continue;
}
if (options === undefined) {
return originalReadFileSync(fallbackPath);
}
return originalReadFileSync(fallbackPath, options);
}
throw error;
}
}
return originalReadFileSync(...args);
}) as ReadFileSync;
try {
mutableFs.readFileSync = patchedReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: patchedReadFileSync,
writable: true,
configurable: true,
});
}
return () => {
try {
mutableFs.readFileSync = originalReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: originalReadFileSync,
writable: true,
configurable: true,
});
}
};
}
/**
* Load the photon module asynchronously.
* Returns cached module on subsequent calls.
*/
export async function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null> {
if (photonModule) {
return photonModule;
}
if (loadPromise) {
return loadPromise;
}
loadPromise = (async () => {
const restoreReadFileSync = patchPhotonWasmRead();
try {
photonModule = await import("@silvia-odwyer/photon-node");
return photonModule;
} catch {
photonModule = null;
return photonModule;
} finally {
restoreReadFileSync();
}
})();
return loadPromise;
}
@@ -1,223 +0,0 @@
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getBinDir, getSettingsPath } from "../config.js";
import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
/**
* Find bash executable on PATH (cross-platform)
*/
function findBashOnPath(): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
try {
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch && existsSync(firstMatch)) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
// Unix: Use 'which' and trust its output (handles Termux and special filesystems)
try {
const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get shell configuration based on platform.
* Resolution order:
* 1. User-specified shellPath in settings.json
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
*/
export function getShellConfig(): { shell: string; args: string[] } {
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${getSettingsPath()}`,
);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
cachedShellConfig = { shell: path, args: ["-c"] };
return cachedShellConfig;
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
` 3. Set shellPath in ${getSettingsPath()}\n\n` +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
if (existsSync("/bin/bash")) {
cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
return cachedShellConfig;
}
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
cachedShellConfig = { shell: "sh", args: ["-c"] };
return cachedShellConfig;
}
export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
const currentPath = process.env[pathKey] ?? "";
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const hasBinDir = pathEntries.includes(binDir);
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
return {
...process.env,
[pathKey]: updatedPath,
};
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
* - Characters with undefined code points
*/
export function sanitizeBinaryOutput(str: string): string {
// Use Array.from to properly iterate over code points (not code units)
// This handles surrogate pairs correctly and catches edge cases where
// codePointAt() might return undefined
return Array.from(str)
.filter((char) => {
// Filter out characters that cause string-width to crash
// This includes:
// - Unicode format characters
// - Lone surrogates (already filtered by Array.from)
// - Control chars except \t \n \r
// - Characters with undefined code points
const code = char.codePointAt(0);
// Skip if code point is undefined (edge case with invalid strings)
if (code === undefined) return false;
// Allow tab, newline, carriage return
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
if (code <= 0x1f) return false;
// Filter out Unicode format characters
if (code >= 0xfff9 && code <= 0xfffb) return false;
return true;
})
.join("");
}
/**
* Detached child processes must be tracked so they can be killed on parent
* shutdown signals (SIGHUP/SIGTERM).
*/
const trackedDetachedChildPids = new Set<number>();
export function trackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.add(pid);
}
export function untrackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.delete(pid);
}
export function killTrackedDetachedChildren(): void {
for (const pid of trackedDetachedChildPids) {
killProcessTree(pid);
}
trackedDetachedChildPids.clear();
}
/**
* Kill a process and all its children (cross-platform)
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}
@@ -1,18 +0,0 @@
/**
* Sleep helper that respects abort signal.
*/
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Aborted"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timeout);
reject(new Error("Aborted"));
});
});
}
@@ -1,287 +0,0 @@
import chalk from "chalk";
import { spawnSync } from "child_process";
import extractZip from "extract-zip";
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
import { arch, platform } from "os";
import { join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { APP_NAME, getBinDir } from "../config.js";
const TOOLS_DIR = getBinDir();
const NETWORK_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 120_000;
function isOfflineModeEnabled(): boolean {
const value = process.env.PI_OFFLINE;
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
interface ToolConfig {
name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd")
binaryName: string; // Name of the binary inside the archive
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
getAssetName: (version: string, plat: string, architecture: string) => string | null;
}
const TOOLS: Record<string, ToolConfig> = {
fd: {
name: "fd",
repo: "sharkdp/fd",
binaryName: "fd",
tagPrefix: "v",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
rg: {
name: "ripgrep",
repo: "BurntSushi/ripgrep",
binaryName: "rg",
tagPrefix: "",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
if (architecture === "arm64") {
return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
}
return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
};
// Check if a command exists in PATH by trying to run it
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
// Check for ENOENT error (command not found)
return result.error === undefined || result.error === null;
} catch {
return false;
}
}
// Get the path to a tool (system-wide or in our tools dir)
export function getToolPath(tool: "fd" | "rg"): string | null {
const config = TOOLS[tool];
if (!config) return null;
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
if (existsSync(localPath)) {
return localPath;
}
// Check system PATH - if found, just return the command name (it's in PATH)
if (commandExists(config.binaryName)) {
return config.binaryName;
}
return null;
}
// Fetch latest release version from GitHub
async function getLatestVersion(repo: string): Promise<string> {
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { tag_name: string };
return data.tag_name.replace(/^v/, "");
}
// Download a file from URL
async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetch(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Failed to download: ${response.status}`);
}
if (!response.body) {
throw new Error("No response body");
}
const fileStream = createWriteStream(dest);
await pipeline(Readable.fromWeb(response.body as any), fileStream);
}
function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {
const stack: string[] = [rootDir];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentDir, entry.name);
if (entry.isFile() && entry.name === binaryFileName) {
return fullPath;
}
if (entry.isDirectory()) {
stack.push(fullPath);
}
}
}
return null;
}
// Download and install a tool
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
const config = TOOLS[tool];
if (!config) throw new Error(`Unknown tool: ${tool}`);
const plat = platform();
const architecture = arch();
// Get latest version
const version = await getLatestVersion(config.repo);
// Get asset name for this platform
const assetName = config.getAssetName(version, plat, architecture);
if (!assetName) {
throw new Error(`Unsupported platform: ${plat}/${architecture}`);
}
// Create tools directory
mkdirSync(TOOLS_DIR, { recursive: true });
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
const archivePath = join(TOOLS_DIR, assetName);
const binaryExt = plat === "win32" ? ".exe" : "";
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
// Download
await downloadFile(downloadUrl, archivePath);
// Extract into a unique temp directory. fd and rg downloads can run concurrently
// during startup, so sharing a fixed directory causes races.
const extractDir = join(
TOOLS_DIR,
`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
);
mkdirSync(extractDir, { recursive: true });
try {
if (assetName.endsWith(".tar.gz")) {
const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" });
if (extractResult.error || extractResult.status !== 0) {
const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error";
throw new Error(`Failed to extract ${assetName}: ${errMsg}`);
}
} else if (assetName.endsWith(".zip")) {
await extractZip(archivePath, { dir: extractDir });
} else {
throw new Error(`Unsupported archive format: ${assetName}`);
}
// Find the binary in extracted files. Some archives contain files directly
// at root, others nest under a versioned subdirectory.
const binaryFileName = config.binaryName + binaryExt;
const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, ""));
const extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];
let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));
if (!extractedBinary) {
extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;
}
if (extractedBinary) {
renameSync(extractedBinary, binaryPath);
} else {
throw new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);
}
// Make executable (Unix only)
if (plat !== "win32") {
chmodSync(binaryPath, 0o755);
}
} finally {
// Cleanup
rmSync(archivePath, { force: true });
rmSync(extractDir, { recursive: true, force: true });
}
return binaryPath;
}
// Termux package names for tools
const TERMUX_PACKAGES: Record<string, string> = {
fd: "fd",
rg: "ripgrep",
};
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
}
const config = TOOLS[tool];
if (!config) return undefined;
if (isOfflineModeEnabled()) {
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));
}
return undefined;
}
// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
// Users must install via pkg.
if (platform() === "android") {
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));
}
return undefined;
}
// Tool not found - download it
if (!silent) {
console.log(chalk.dim(`${config.name} not found. Downloading...`));
}
try {
const path = await downloadTool(tool);
if (!silent) {
console.log(chalk.dim(`${config.name} installed to ${path}`));
}
return path;
} catch (e) {
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
return undefined;
}
}