Files
oh-my-pi/packages/coding-agent/src/tools/path-utils.ts
T
can1357 d6d15a2937 refactor(tools): consolidated fetch into read tool with URL caching
- Consolidated fetch tool into read tool with URL reading capability and caching support.
- Removed standalone fetch tool from all agent prompts and CLI documentation.
- Extended read tool schema with timeout and raw parameters for URL fetch control.
- Added URL caching mechanism to prevent redundant network requests during read operations.
- Refactored fetch module from class-based tool to standalone executeReadUrl function.
- Updated read tool documentation to describe multi-purpose capabilities including web pages, GitHub, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, blogs, and feeds.
2026-04-02 05:59:50 +02:00

561 lines
16 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as url from "node:url";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
"agent://",
"artifact://",
"skill://",
"rule://",
"local://",
"mcp://",
] as const;
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 tryShellEscapedPath(filePath: string): string {
if (!filePath.includes("\\") || !filePath.includes("/")) return filePath;
return filePath.replace(/\\([ \t"'(){}[\]])/g, "$1");
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(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://") ||
withoutAt.startsWith("local://") ||
withoutAt.startsWith("mcp://")
) {
return withoutAt;
}
return filePath;
}
function stripFileUrl(filePath: string): string {
if (!filePath.toLowerCase().startsWith("file://")) return filePath;
try {
return url.fileURLToPath(filePath);
} catch {
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 = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
return expandTilde(normalized);
}
/**
* Resolve a path relative to the given cwd.
* Handles ~ expansion and absolute paths.
*
* A bare root slash is treated as a workspace-root alias for tool inputs. Users
* often pass `/` to mean “search from here”, and letting tools escape to the
* filesystem root is almost never what they intended.
*/
export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (/^\/+$/.test(expanded)) {
return cwd;
}
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
/**
* Strip matching surrounding double quotes from a path string.
* Common when users paste quoted paths from Windows Explorer or shell copy-paste.
* Only double quotes — single quotes are valid POSIX filename characters.
* Tradeoff: a POSIX path literally starting AND ending with " would also be unquoted.
* Accepted because such names are virtually nonexistent in practice.
*/
export function stripOuterDoubleQuotes(input: string): string {
return input.startsWith('"') && input.endsWith('"') && input.length > 1 ? input.slice(1, -1) : input;
}
export function normalizePathLikeInput(input: string): string {
return stripOuterDoubleQuotes(input.trim());
}
const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
export function hasGlobPathChars(filePath: string): boolean {
return GLOB_PATH_CHARS.some(char => filePath.includes(char));
}
export interface ParsedSearchPath {
basePath: string;
glob?: string;
}
export interface ParsedFindPattern {
basePath: string;
globPattern: string;
hasGlob: boolean;
}
export interface ResolvedMultiSearchPath {
basePath: string;
glob?: string;
scopePath: string;
}
export interface ResolvedMultiFindPattern {
basePath: string;
globPattern: string;
scopePath: string;
}
/**
* Split a user path into a base path + glob pattern for tools that delegate to
* APIs accepting separate `path` and `glob` arguments.
*/
export function parseSearchPath(filePath: string): ParsedSearchPath {
const normalizedPath = filePath.replace(/\\/g, "/");
if (!hasGlobPathChars(normalizedPath)) {
return { basePath: filePath };
}
const segments = normalizedPath.split("/");
const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
if (firstGlobIndex <= 0) {
return { basePath: ".", glob: normalizedPath };
}
return {
basePath: segments.slice(0, firstGlobIndex).join("/"),
glob: segments.slice(firstGlobIndex).join("/"),
};
}
// Parse a find pattern into a base directory path and a glob pattern.
// Examples:
// src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
// src/app/\*.tsx -> { basePath: "src/app", globPattern: "*.tsx", hasGlob: true }
// \*.ts -> { basePath: ".", globPattern: "**/*.ts", hasGlob: true }
// **/\*.json -> { basePath: ".", globPattern: "**/*.json", hasGlob: true }
// /abs/path/**/\*.ts -> { basePath: "/abs/path", globPattern: "**/*.ts", hasGlob: true }
// src/app -> { basePath: "src/app", globPattern: "**/*", hasGlob: false }
export function parseFindPattern(pattern: string): ParsedFindPattern {
const segments = pattern.split("/");
let firstGlobIndex = -1;
for (let i = 0; i < segments.length; i++) {
if (hasGlobPathChars(segments[i])) {
firstGlobIndex = i;
break;
}
}
if (firstGlobIndex === -1) {
return { basePath: pattern, globPattern: "**/*", hasGlob: false };
}
if (firstGlobIndex === 0) {
const needsRecursive = !pattern.startsWith("**/");
return {
basePath: ".",
globPattern: needsRecursive ? `**/${pattern}` : pattern,
hasGlob: true,
};
}
return {
basePath: segments.slice(0, firstGlobIndex).join("/"),
globPattern: segments.slice(firstGlobIndex).join("/"),
hasGlob: true,
};
}
export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): string | undefined {
if (!prefixGlob) return suffixGlob;
if (!suffixGlob) return prefixGlob;
const normalizedPrefix = prefixGlob.replace(/\/+$/, "");
const normalizedSuffix = suffixGlob.replace(/^\/+/, "");
return `${normalizedPrefix}/${normalizedSuffix}`;
}
type TopLevelSeparator = "comma" | "whitespace";
function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
const parts: string[] = [];
let current = "";
let braceDepth = 0;
let bracketDepth = 0;
let parenDepth = 0;
let quote: '"' | "'" | undefined;
let escaped = false;
const pushCurrent = () => {
const normalized = current.trim();
if (normalized.length > 0) {
parts.push(normalized);
}
current = "";
};
for (const char of value) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "{") braceDepth += 1;
else if (char === "}" && braceDepth > 0) braceDepth -= 1;
else if (char === "[") bracketDepth += 1;
else if (char === "]" && bracketDepth > 0) bracketDepth -= 1;
else if (char === "(") parenDepth += 1;
else if (char === ")" && parenDepth > 0) parenDepth -= 1;
const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0;
const isWhitespace = /\s/.test(char);
if (topLevel && separator === "comma" && char === ",") {
pushCurrent();
continue;
}
if (topLevel && separator === "whitespace" && isWhitespace) {
pushCurrent();
continue;
}
current += char;
}
pushCurrent();
return parts.length > 1 ? parts : [value.trim()];
}
function normalizePosixPath(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
function joinRelativeGlob(basePath: string | undefined, globPattern: string): string {
if (!basePath || basePath === ".") return normalizePosixPath(globPattern).replace(/^\/+/, "");
const normalizedBase = normalizePosixPath(basePath).replace(/\/+$/, "");
const normalizedGlob = normalizePosixPath(globPattern).replace(/^\/+/, "");
return `${normalizedBase}/${normalizedGlob}`;
}
function buildBraceUnion(patterns: string[]): string | undefined {
const uniquePatterns = [...new Set(patterns.map(pattern => normalizePosixPath(pattern).trim()).filter(Boolean))];
if (uniquePatterns.length === 0) return undefined;
if (uniquePatterns.length === 1) return uniquePatterns[0];
return `{${uniquePatterns.join(",")}}`;
}
function findCommonBasePath(paths: string[]): string {
if (paths.length === 0) return ".";
let commonParts = path.resolve(paths[0]).split(path.sep);
for (const candidatePath of paths.slice(1)) {
const candidateParts = path.resolve(candidatePath).split(path.sep);
let sharedCount = 0;
const maxShared = Math.min(commonParts.length, candidateParts.length);
while (sharedCount < maxShared && commonParts[sharedCount] === candidateParts[sharedCount]) {
sharedCount += 1;
}
commonParts = commonParts.slice(0, sharedCount);
}
if (commonParts.length === 0) {
return path.parse(path.resolve(paths[0])).root;
}
const joined = commonParts.join(path.sep);
return joined || path.parse(path.resolve(paths[0])).root;
}
function toScopeDisplay(items: string[]): string {
return items.map(item => normalizePosixPath(item)).join(", ");
}
function looksLikeDelimitedPathToken(token: string): boolean {
return (
TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) ||
token.startsWith(".") ||
token.startsWith("/") ||
token.startsWith("~") ||
token.startsWith("@") ||
token.includes("/") ||
token.includes("\\") ||
hasGlobPathChars(token) ||
/\.[^./\\]+$/.test(token)
);
}
async function areDelimitedTokensResolvable(
tokens: string[],
cwd: string,
parseBasePath: (value: string) => string,
allowBareExistingTokens: boolean,
): Promise<boolean> {
for (const token of tokens) {
if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) {
return false;
}
if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) {
// Bare names like "packages" don't look like path tokens syntactically,
// but may still be valid directory names. Check existence before rejecting.
const resolvedExactPath = resolveToCwd(token, cwd);
if (!(await pathExists(resolvedExactPath))) {
return false;
}
continue;
}
const basePath = parseBasePath(token);
const resolvedBasePath = resolveToCwd(basePath, cwd);
if (await pathExists(resolvedBasePath)) {
continue;
}
if (!allowBareExistingTokens) {
return false;
}
const resolvedExactPath = resolveToCwd(token, cwd);
if (!(await pathExists(resolvedExactPath))) {
return false;
}
}
return true;
}
async function splitDelimitedSearchInput(
rawInput: string,
cwd: string,
parseBasePath: (value: string) => string,
): Promise<string[] | undefined> {
const trimmed = rawInput.trim();
if (!trimmed) return undefined;
const resolvedExactPath = resolveToCwd(trimmed, cwd);
if (await pathExists(resolvedExactPath)) {
return undefined;
}
const commaSeparated = splitTopLevel(trimmed, "comma");
if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) {
return [...new Set(commaSeparated)];
}
const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
if (
whitespaceSeparated.length > 1 &&
(await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false))
) {
return [...new Set(whitespaceSeparated)];
}
return undefined;
}
export async function resolveMultiSearchPath(
rawPath: string,
cwd: string,
suffixGlob?: string,
): Promise<ResolvedMultiSearchPath | undefined> {
const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
if (!pathItems || pathItems.length <= 1) {
return undefined;
}
const parsedItems = await Promise.all(
pathItems.map(async item => {
const parsedPath = parseSearchPath(item);
const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
const stat = await fs.promises.stat(absoluteBasePath);
return { raw: item, parsedPath, absoluteBasePath, stat };
}),
);
const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
const combinedPatterns = parsedItems.map(item => {
const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
if (item.parsedPath.glob) {
const pathGlob = joinRelativeGlob(relativeBasePath, item.parsedPath.glob);
return combineSearchGlobs(pathGlob, suffixGlob) ?? pathGlob;
}
if (suffixGlob) {
const pathPrefix = relativeBasePath === "." ? undefined : relativeBasePath;
return combineSearchGlobs(pathPrefix, suffixGlob) ?? suffixGlob;
}
if (item.stat.isDirectory()) {
return joinRelativeGlob(relativeBasePath, "**/*");
}
return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
});
return {
basePath: commonBasePath,
glob: buildBraceUnion(combinedPatterns),
scopePath: toScopeDisplay(pathItems),
};
}
export async function resolveMultiFindPattern(
rawPattern: string,
cwd: string,
): Promise<ResolvedMultiFindPattern | undefined> {
const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath);
if (!patternItems || patternItems.length <= 1) {
return undefined;
}
const parsedItems = await Promise.all(
patternItems.map(async item => {
const parsedPattern = parseFindPattern(item);
const absoluteBasePath = resolveToCwd(parsedPattern.basePath, cwd);
const stat = await fs.promises.stat(absoluteBasePath);
return { raw: item, parsedPattern, absoluteBasePath, stat };
}),
);
const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
const combinedPatterns = parsedItems.map(item => {
const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
if (item.parsedPattern.hasGlob) {
return joinRelativeGlob(relativeBasePath, item.parsedPattern.globPattern);
}
if (item.stat.isDirectory()) {
return joinRelativeGlob(relativeBasePath, "**/*");
}
return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
});
return {
basePath: commonBasePath,
globPattern: buildBraceUnion(combinedPatterns) ?? "**/*",
scopePath: toScopeDisplay(patternItems),
};
}
export function resolveReadPath(filePath: string, cwd: string): string {
const resolved = resolveToCwd(filePath, cwd);
const shellEscapedVariant = tryShellEscapedPath(resolved);
const baseCandidates = shellEscapedVariant !== resolved ? [resolved, shellEscapedVariant] : [resolved];
for (const baseCandidate of baseCandidates) {
if (fileExists(baseCandidate)) {
return baseCandidate;
}
}
for (const baseCandidate of baseCandidates) {
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
const amPmVariant = tryMacOSScreenshotPath(baseCandidate);
if (amPmVariant !== baseCandidate && fileExists(amPmVariant)) {
return amPmVariant;
}
// Try NFD variant (macOS stores filenames in NFD form)
const nfdVariant = tryNFDVariant(baseCandidate);
if (nfdVariant !== baseCandidate && fileExists(nfdVariant)) {
return nfdVariant;
}
// Try curly quote variant (macOS uses U+2019 in screenshot names)
const curlyVariant = tryCurlyQuoteVariant(baseCandidate);
if (curlyVariant !== baseCandidate && fileExists(curlyVariant)) {
return curlyVariant;
}
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
if (nfdCurlyVariant !== baseCandidate && fileExists(nfdCurlyVariant)) {
return nfdCurlyVariant;
}
}
return resolved;
}