import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; export interface VideoTuiConfig { enabled: boolean; videoPath: string | undefined; fps: number; maxWidth: number | null; maxRows: number | null; maxHeightPercent: number; volume: number; nativeBinary: string | undefined; } export const DEFAULT_CONFIG: Readonly = Object.freeze({ enabled: true, videoPath: undefined, fps: 15, maxWidth: null, maxRows: null, maxHeightPercent: 100, volume: 100, nativeBinary: undefined, }); const CONFIG_FILE_NAME = "pi-video-tui.json"; const CONFIG_KEYS = new Set([ "enabled", "videoPath", "fps", "maxWidth", "maxRows", "maxHeightPercent", "volume", "nativeBinary", ]); export async function loadVideoTuiConfig( cwd: string, projectTrusted: boolean, ): Promise { const globalDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); const layers: unknown[] = [await readConfigFile(join(globalDirectory, CONFIG_FILE_NAME))]; if (projectTrusted) { const projectDirectory = join(cwd, CONFIG_DIR_NAME); layers.push(await readConfigFile(join(projectDirectory, CONFIG_FILE_NAME))); } layers.push(environmentConfig()); return resolveVideoTuiConfig(...layers); } export function resolveVideoTuiConfig(...layers: unknown[]): VideoTuiConfig { const merged: VideoTuiConfig = { ...DEFAULT_CONFIG }; for (const layer of layers) { if (layer === undefined) continue; if (!isRecord(layer)) throw new Error("pi-video-tui config must be a JSON object"); for (const [key, value] of Object.entries(layer)) { if (!CONFIG_KEYS.has(key as keyof VideoTuiConfig)) { throw new Error(`Unknown pi-video-tui config key: ${key}`); } (merged as unknown as Record)[key] = value; } } validateConfig(merged); return merged; } async function readConfigFile(path: string): Promise { let contents: string; try { contents = await readFile(path, "utf8"); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw new Error(`Failed to read ${path}: ${errorMessage(error)}`); } try { return JSON.parse(contents) as unknown; } catch (error) { throw new Error(`Invalid JSON in ${path}: ${errorMessage(error)}`); } } function environmentConfig(): Partial { const layer: Partial = {}; const videoPath = process.env.PI_VIDEO_TUI_VIDEO; const nativeBinary = process.env.PI_VIDEO_TUI_NATIVE; const disabled = process.env.PI_VIDEO_TUI_DISABLED; if (videoPath) layer.videoPath = videoPath; if (nativeBinary) layer.nativeBinary = nativeBinary; if (disabled !== undefined) { layer.enabled = !["1", "true", "yes"].includes(disabled.toLowerCase()); } return layer; } function validateConfig(config: VideoTuiConfig): void { if (typeof config.enabled !== "boolean") throw new Error("enabled must be a boolean"); if (config.videoPath !== undefined && ( typeof config.videoPath !== "string" || config.videoPath.length === 0 )) { throw new Error("videoPath must be a non-empty string when provided"); } integerInRange(config.fps, "fps", 1, 60); nullableIntegerInRange(config.maxWidth, "maxWidth", 20, 1024); nullableIntegerInRange(config.maxRows, "maxRows", 1, 512); integerInRange(config.maxHeightPercent, "maxHeightPercent", 1, 100); integerInRange(config.volume, "volume", 0, 100); if (config.nativeBinary !== undefined && (typeof config.nativeBinary !== "string" || config.nativeBinary.length === 0)) { throw new Error("nativeBinary must be a non-empty string when provided"); } } function nullableIntegerInRange( value: unknown, name: string, minimum: number, maximum: number, ): asserts value is number | null { if (value === null) return; integerInRange(value, name, minimum, maximum); } function integerInRange(value: unknown, name: string, minimum: number, maximum: number): asserts value is number { if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) { throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`); } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }