Files
oh-my-pi/packages/coding-agent/src/tools/path-utils.ts
T

123 lines
3.5 KiB
TypeScript

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;
}