1709172bfe
- Added vault:// URL parsing, typed variants, and path resolution with vault-root validation. - Added VaultProtocolHandler with fs and Obsidian CLI-backed resolve/read/write/list support plus caching. - Added vault scheme integration in router, path utils, and plan-mode guard using resolveVaultUrlToPath. - Documented vault:// read/edit and `?op`-scoped URI formats in system prompts when Obsidian is available. - Secured vault:// operations by rejecting traversal, absolute, and symlink-escape path cases. - Fixed response.incomplete recovery by dropping truncated turns and promoting context. - Added internal tests for vault protocol parsing, caching, CLI behavior, and invalid-path defenses.
848 lines
29 KiB
TypeScript
848 lines
29 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";
|
|
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
|
|
// `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
|
|
// excluded because mcp resource URIs may legitimately contain colons.
|
|
const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
|
|
agent: true,
|
|
artifact: true,
|
|
issue: true,
|
|
local: true,
|
|
memory: true,
|
|
omp: true,
|
|
pr: true,
|
|
rule: true,
|
|
skill: true,
|
|
vault: true,
|
|
};
|
|
// Schemes whose resource URIs are server-defined and may legitimately end
|
|
// with selector-shaped tails (e.g. `:raw`, `:conflicts`, `:1-50`, `/:raw`).
|
|
// `McpProtocolHandler` resolves by exact URI match (`r.uri === uri`), so
|
|
// peeling syntactically can make valid resources unreachable. Keep these
|
|
// schemes opaque; selector support for them needs a resolver-aware path that
|
|
// tries the exact URI before interpreting any suffix as a read selector.
|
|
const OPAQUE_RESOURCE_SCHEMES: ReadonlySet<string> = new Set(["mcp"]);
|
|
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://",
|
|
"vault://",
|
|
] 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);
|
|
}
|
|
/**
|
|
* Inclusive line range describing one selector segment (e.g. `50-100`,
|
|
* `301-`, or `50+10`). `endLine` is `undefined` for open-ended ranges.
|
|
*/
|
|
export interface LineRange {
|
|
startLine: number;
|
|
endLine: number | undefined;
|
|
}
|
|
|
|
const LINE_RANGE_CHUNK_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
|
|
|
|
/** Parse a single `N`, `N-M`, `N-`, or `N+K` chunk. Throws via {@link ToolError} on invalid bounds. */
|
|
export function parseLineRangeChunk(sel: string): LineRange | null {
|
|
const lineMatch = LINE_RANGE_CHUNK_RE.exec(sel);
|
|
if (!lineMatch) return null;
|
|
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
if (rawStart < 1) {
|
|
throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
|
|
}
|
|
const sep = lineMatch[2];
|
|
const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
|
|
let rawEnd: number | undefined;
|
|
if (sep === "+") {
|
|
if (rhs === undefined || rhs < 1) {
|
|
throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
|
|
}
|
|
rawEnd = rawStart + rhs - 1;
|
|
} else if (sep === "-") {
|
|
// `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
|
|
if (rhs !== undefined) {
|
|
if (rhs < rawStart) {
|
|
throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
|
|
}
|
|
rawEnd = rhs;
|
|
}
|
|
}
|
|
return { startLine: rawStart, endLine: rawEnd };
|
|
}
|
|
|
|
/**
|
|
* Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
|
|
* the ranges in ascending order with overlapping/adjacent ranges merged so
|
|
* downstream consumers can stream the file in a single forward pass per range.
|
|
*/
|
|
export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
|
|
const chunks = sel.split(",");
|
|
const parsed: LineRange[] = [];
|
|
for (const chunk of chunks) {
|
|
const range = parseLineRangeChunk(chunk);
|
|
if (!range) return null;
|
|
parsed.push(range);
|
|
}
|
|
if (parsed.length === 0) return null;
|
|
parsed.sort((a, b) => a.startLine - b.startLine);
|
|
|
|
const merged: LineRange[] = [parsed[0]];
|
|
for (let i = 1; i < parsed.length; i++) {
|
|
const current = parsed[i];
|
|
const last = merged[merged.length - 1];
|
|
// Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
|
|
if (last.endLine === undefined) continue;
|
|
// Merge when current starts within (or immediately after) the last range.
|
|
if (current.startLine <= last.endLine + 1) {
|
|
if (current.endLine === undefined || current.endLine > last.endLine) {
|
|
merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
|
|
}
|
|
continue;
|
|
}
|
|
merged.push(current);
|
|
}
|
|
return merged as [LineRange, ...LineRange[]];
|
|
}
|
|
|
|
/** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
|
|
export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
|
|
for (const range of ranges) {
|
|
if (lineNumber < range.startLine) continue;
|
|
if (range.endLine === undefined || lineNumber <= range.endLine) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 `:<chunk>` 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 `:<chunk>` 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 };
|
|
const scheme = schemeMatch[1].toLowerCase();
|
|
// Opaque schemes (mcp://, etc.) carry server-defined resource URIs that may
|
|
// legitimately end in selector-shaped tails. Forward verbatim — see
|
|
// OPAQUE_RESOURCE_SCHEMES.
|
|
if (OPAQUE_RESOURCE_SCHEMES.has(scheme)) return { path: rawPath };
|
|
if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) 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<ResolvedMultiSearchPath | undefined> {
|
|
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<ResolvedMultiSearchPath | undefined> {
|
|
return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
|
|
}
|
|
|
|
async function resolveFindPatternItems(
|
|
patternItems: string[],
|
|
cwd: string,
|
|
): Promise<ResolvedMultiFindPattern | undefined> {
|
|
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<ResolvedMultiFindPattern | undefined> {
|
|
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<PartitionedPaths> {
|
|
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<string>;
|
|
}
|
|
|
|
/**
|
|
* 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<ToolScopeResolution> {
|
|
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<string>();
|
|
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,
|
|
};
|
|
}
|