import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); } function tryMacOSScreenshotPath(filePath: string): string { return filePath.replace(/ (AM|PM)\./g, `${NARROW_NO_BREAK_SPACE}$1.`); } function tryNFDVariant(filePath: string): string { // macOS stores filenames in NFD (decomposed) form, try converting user input to NFD return filePath.normalize("NFD"); } function tryCurlyQuoteVariant(filePath: string): string { // macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran" // Users typically type U+0027 (straight apostrophe) return filePath.replace(/'/g, "\u2019"); } function fileExists(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.F_OK); return true; } catch { return false; } } function normalizeAtPrefix(filePath: string): string { if (!filePath.startsWith("@")) return filePath; const withoutAt = filePath.slice(1); // We only treat a leading "@" as a shorthand for a small set of well-known // syntaxes. This avoids mangling literal paths like "@my-file.txt". if ( withoutAt.startsWith("/") || withoutAt === "~" || withoutAt.startsWith("~/") || // Windows absolute paths (drive letters / UNC / root-relative) path.win32.isAbsolute(withoutAt) || // Internal URL shorthands withoutAt.startsWith("agent://") || withoutAt.startsWith("artifact://") || withoutAt.startsWith("skill://") || withoutAt.startsWith("rule://") ) { return withoutAt; } return filePath; } export function expandTilde(filePath: string, home?: string): string { const h = home ?? os.homedir(); if (filePath === "~") return h; if (filePath.startsWith("~/") || filePath.startsWith("~\\")) { return h + filePath.slice(1); } if (filePath.startsWith("~")) { return path.join(h, filePath.slice(1)); } return filePath; } export function expandPath(filePath: string): string { const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); return expandTilde(normalized); } /** * Resolve a path relative to the given cwd. * Handles ~ expansion and absolute paths. */ export function resolveToCwd(filePath: string, cwd: string): string { const expanded = expandPath(filePath); if (path.isAbsolute(expanded)) { return expanded; } return path.resolve(cwd, expanded); } export function resolveReadPath(filePath: string, cwd: string): string { const resolved = resolveToCwd(filePath, cwd); if (fileExists(resolved)) { return resolved; } // Try macOS AM/PM variant (narrow no-break space before AM/PM) const amPmVariant = tryMacOSScreenshotPath(resolved); if (amPmVariant !== resolved && fileExists(amPmVariant)) { return amPmVariant; } // Try NFD variant (macOS stores filenames in NFD form) const nfdVariant = tryNFDVariant(resolved); if (nfdVariant !== resolved && fileExists(nfdVariant)) { return nfdVariant; } // Try curly quote variant (macOS uses U+2019 in screenshot names) const curlyVariant = tryCurlyQuoteVariant(resolved); if (curlyVariant !== resolved && fileExists(curlyVariant)) { return curlyVariant; } // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) { return nfdCurlyVariant; } return resolved; }