import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { InternalUrlRouter } from "../internal-urls"; import { ToolError } from "./tool-errors"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i; const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i; const FILE_RAW_ONLY_RE = /^raw$/i; // Permissive selector chunk for internal URLs — accepts well-formed selectors // plus common malformed shapes (e.g. `:-N`) so the read tool peels the entire // selector chain off before dispatching to a protocol handler. const INTERNAL_URL_SELECTOR_PART_RE = /^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i; // Schemes whose host grammar is identifier-shaped, so any trailing // `:` is unambiguously a read-tool selector. `mcp://` is // excluded because mcp resource URIs may legitimately contain colons. const INTERNAL_SCHEMES_WITH_SELECTORS: Record = { agent: true, artifact: true, issue: true, local: true, memory: true, omp: true, pr: true, rule: true, skill: true, }; const INTERNAL_URL_SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i; 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; } } 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); } export function splitPathAndSel(rawPath: string): { path: string; sel?: string } { const colon = rawPath.lastIndexOf(":"); if (colon <= 0) return { path: rawPath }; const candidate = rawPath.slice(colon + 1); if (!FILE_LINE_RANGE_RE.test(candidate)) return { path: rawPath }; let basePath = rawPath.slice(0, colon); let sel = candidate; // Allow a compound trailing selector: `path:1-50:raw` or `path:raw:1-50`. // The two chunks must be one line-range plus one `raw`, in either order. const innerColon = basePath.lastIndexOf(":"); if (innerColon > 0) { const innerCandidate = basePath.slice(innerColon + 1); const innerIsRaw = FILE_RAW_ONLY_RE.test(innerCandidate); const outerIsRaw = FILE_RAW_ONLY_RE.test(candidate); const innerIsRange = FILE_LINE_RANGE_ONLY_RE.test(innerCandidate); const outerIsRange = FILE_LINE_RANGE_ONLY_RE.test(candidate); if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) { sel = `${innerCandidate}:${candidate}`; basePath = basePath.slice(0, innerColon); } } return { path: basePath, sel }; } /** * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`). * * The filesystem-path splitter is intentionally conservative: it refuses to * peel a trailing `:` unless that chunk matches the strict selector * grammar. That rule is right for filesystem paths (a file named `a:1-50` is * legal) but wrong for internal URLs, where any trailing `:` after the * scheme is unambiguously a read-tool selector — even if malformed (e.g. * `artifact://3:raw:-100`). * * This function iteratively peels selector-shaped chunks (well-formed plus * common malformed shapes like `:-N`) so the rest of the read tool can pass a * clean URL to the protocol handler and surface selector errors via parseSel * instead of as misleading "host invalid" errors from the handler. Schemes * whose resource URIs may legitimately contain colons (`mcp://`) are skipped. * * Falls back to the input unchanged when nothing matches. */ export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } { const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE); if (!schemeMatch) return { path: rawPath }; if (!INTERNAL_SCHEMES_WITH_SELECTORS[schemeMatch[1].toLowerCase()]) return { path: rawPath }; const schemeEnd = schemeMatch[0].length; let path = rawPath; const chunks: string[] = []; while (true) { const colon = path.lastIndexOf(":"); // Stop before crossing into the scheme separator `://`. if (colon < schemeEnd) break; const tail = path.slice(colon + 1); if (!INTERNAL_URL_SELECTOR_PART_RE.test(tail)) break; chunks.unshift(tail); path = path.slice(0, colon); } if (chunks.length === 0) return { path: rawPath }; return { path, sel: chunks.join(":") }; } function assertNotInternalUrl(expanded: string, original: string): void { for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) { if (expanded.startsWith(prefix)) { throw new Error( `Path "${original}" uses internal scheme "${prefix}" and must be resolved through the proper protocol handler, not as a filesystem path.`, ); } } } export function normalizeLocalScheme(filePath: string): string { return filePath.replace(/^(local:)\/(?!\/)/, "$1//"); } export function isInternalUrlPath(filePath: string): boolean { const normalized = normalizeLocalScheme(filePath); const expandedAndNormalized = normalizeLocalScheme(expandPath(normalized)); for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) { if (expandedAndNormalized.startsWith(prefix)) return true; } return false; } /** * 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 normalized = normalizeLocalScheme(filePath); const expanded = expandPath(normalized); const expandedAndNormalized = normalizeLocalScheme(expanded); assertNotInternalUrl(expandedAndNormalized, normalized); if (/^\/+$/.test(expanded)) { return cwd; } if (path.isAbsolute(expanded)) { return expanded; } return path.resolve(cwd, expanded); } export function formatPathRelativeToCwd( filePath: string, cwd: string, options: { trailingSlash?: boolean } = {}, ): string { const resolvedCwd = path.resolve(cwd); const normalized = normalizeLocalScheme(filePath); if (isInternalUrlPath(normalized)) { return normalized; } const expanded = expandPath(normalized); const resolvedPath = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded); const relative = path.relative(resolvedCwd, resolvedPath); const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); let displayPath = normalizePosixPath(isWithinCwd ? relative || "." : resolvedPath); if (options.trailingSlash && displayPath !== "." && !displayPath.endsWith("/")) { displayPath += "/"; } return displayPath; } /** * 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 ResolvedSearchTarget { basePath: string; glob?: string; } export interface ResolvedMultiSearchPath { basePath: string; glob?: string; scopePath: string; exactFilePaths?: string[]; targets?: ResolvedSearchTarget[]; } 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}`; } 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[], cwd: string): string { return items .map(item => formatPathRelativeToCwd(item, cwd, { trailingSlash: item.endsWith("/") || item.endsWith("\\"), }), ) .join(", "); } async function resolveSearchPathItems( pathItems: string[], cwd: string, suffixGlob?: string, ): Promise { if (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 allExactFiles = !suffixGlob && parsedItems.every(item => !item.parsedPath.glob && item.stat.isFile()); 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; }); const rootPath = path.parse(commonBasePath).root; const isDegenerateRoot = commonBasePath === rootPath && parsedItems.length > 1; const targets = isDegenerateRoot ? parsedItems.map(item => ({ basePath: item.absoluteBasePath, glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob, })) : undefined; return { basePath: commonBasePath, glob: buildBraceUnion(combinedPatterns), scopePath: toScopeDisplay(pathItems, cwd), exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined, targets, }; } export async function resolveExplicitSearchPaths( pathItems: string[], cwd: string, suffixGlob?: string, ): Promise { return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob); } async function resolveFindPatternItems( patternItems: string[], cwd: string, ): Promise { if (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, cwd), }; } export async function resolveExplicitFindPatterns( patternItems: string[], cwd: string, ): Promise { return resolveFindPatternItems([...new Set(patternItems)], cwd); } /** * Result of partitioning a list of user-supplied paths/globs into entries whose * base directory currently exists on disk versus those that do not. * * Used by multi-path tools (search, find, ast_grep, ast_edit) to tolerate one * or more missing entries in a multi-path call: the surviving entries should * still be searched, with the missing entries surfaced as a non-fatal warning. */ export interface PartitionedPaths { /** Raw input strings whose resolved base path exists. */ valid: string[]; /** Raw input strings whose resolved base path is missing (ENOENT). */ missing: string[]; } /** * Stat each input's base path concurrently; return entries split by existence. * * `splitter` is expected to be {@link parseFindPattern} or * {@link parseSearchPath}: both return a `basePath` field that this helper * resolves against `cwd` and stats. ENOENT is the only swallowed error — every * other stat failure (permission, IO, etc.) propagates so callers do not silently * skip paths that exist but are unreadable. * * Order of `valid` and `missing` follows the input order, so callers can rely * on `valid[0]` matching the first surviving user-supplied entry. */ export async function partitionExistingPaths( items: string[], cwd: string, splitter: (item: string) => { basePath: string }, ): Promise { const settled = await Promise.all( items.map(async item => { const { basePath } = splitter(item); const absoluteBasePath = resolveToCwd(basePath, cwd); try { await fs.promises.stat(absoluteBasePath); return { item, exists: true } as const; } catch (err) { if (isEnoent(err)) return { item, exists: false } as const; throw err; } }), ); const valid: string[] = []; const missing: string[] = []; for (const entry of settled) { if (entry.exists) valid.push(entry.item); else missing.push(entry.item); } return { valid, missing }; } 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; } // ============================================================================= // Tool-scope resolution (search/ast tools) // ============================================================================= export interface ToolScopeOptions { rawPaths: string[]; cwd: string; /** Verb used in the "Cannot {action} internal URL without a backing file: …" message. */ internalUrlAction: string; /** Collect absolute paths flagged immutable by their internal-URL handler. */ trackImmutableSources?: boolean; /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */ surfaceExactFilePaths?: boolean; /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */ multipathStatHint?: string; } export interface ToolScopeResolution { searchPath: string; scopePath: string; globFilter: string | undefined; isDirectory: boolean; multiTargets?: ResolvedSearchTarget[]; exactFilePaths?: string[]; missingPaths: string[]; immutableSourcePaths: Set; } /** * Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`: * 1. normalize + reject empty paths, * 2. resolve internal URLs through {@link InternalUrlRouter} to backing files, * 3. partition existing vs missing when multiple paths are supplied, * 4. derive a single search base path / glob, or a multi-target list, * 5. stat the resolved base path so callers can branch on directory vs file scope. */ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise { const { rawPaths: inputs, cwd, internalUrlAction } = opts; const rawPaths = inputs.map(normalizePathLikeInput); if (rawPaths.some(rawPath => rawPath.length === 0)) { throw new ToolError("`paths` must contain non-empty paths or globs"); } const internalRouter = InternalUrlRouter.instance(); const resolvedPathInputs: string[] = []; const immutableSourcePaths = new Set(); for (const rawPath of rawPaths) { if (!internalRouter.canHandle(rawPath)) { resolvedPathInputs.push(rawPath); continue; } if (hasGlobPathChars(rawPath)) { throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); } const resource = await internalRouter.resolve(rawPath); if (!resource.sourcePath) { throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`); } if (opts.trackImmutableSources && resource.immutable) { immutableSourcePaths.add(path.resolve(resource.sourcePath)); } resolvedPathInputs.push(resource.sourcePath); } let missingPaths: string[] = []; let effectivePaths = resolvedPathInputs; if (resolvedPathInputs.length > 1) { const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath); if (partition.valid.length === 0) { throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); } effectivePaths = partition.valid; missingPaths = partition.missing; } let searchPath: string; let scopePath: string; let globFilter: string | undefined; let multiTargets: ResolvedSearchTarget[] | undefined; let exactFilePaths: string[] | undefined; if (effectivePaths.length === 1) { const parsedPath = parseSearchPath(effectivePaths[0] ?? "."); searchPath = resolveToCwd(parsedPath.basePath, cwd); globFilter = parsedPath.glob; scopePath = formatPathRelativeToCwd(searchPath, cwd); } else { const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd); if (!multiSearchPath) { throw new ToolError("`paths` must contain at least one path or glob"); } searchPath = multiSearchPath.basePath; multiTargets = multiSearchPath.targets; if (opts.surfaceExactFilePaths) { exactFilePaths = multiSearchPath.exactFilePaths; globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob; } else { globFilter = multiTargets ? undefined : multiSearchPath.glob; } scopePath = multiSearchPath.scopePath; } let isDirectory: boolean; try { const stat = await Bun.file(searchPath).stat(); isDirectory = stat.isDirectory(); } catch { const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : ""; throw new ToolError(`Path not found: ${scopePath}${hint}`); } return { searchPath, scopePath, globFilter, isDirectory, multiTargets, exactFilePaths, missingPaths, immutableSourcePaths, }; }