From 6dd98254be8ebcc7c028ce536f0b25db37f0594f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Mon, 29 Dec 2025 10:10:22 +0900 Subject: [PATCH] fix: improve glob tool Windows compatibility and rg resolution (#309) --- src/tools/glob/cli.ts | 66 ++++++++++++++++++++++++++++++++----- src/tools/glob/constants.ts | 2 +- src/tools/glob/tools.ts | 13 +++++--- src/tools/grep/constants.ts | 4 +++ 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 20e900b..5646155 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -1,6 +1,7 @@ import { spawn } from "bun" import { resolveGrepCli, + type GrepBackend, DEFAULT_TIMEOUT_MS, DEFAULT_LIMIT, DEFAULT_MAX_DEPTH, @@ -10,6 +11,11 @@ import { import type { GlobOptions, GlobResult, FileMatch } from "./types" import { stat } from "node:fs/promises" +export interface ResolvedCli { + path: string + backend: GrepBackend +} + function buildRgArgs(options: GlobOptions): string[] { const args: string[] = [ ...RG_FILES_FLAGS, @@ -40,6 +46,25 @@ function buildFindArgs(options: GlobOptions): string[] { return args } +function buildPowerShellCommand(options: GlobOptions): string[] { + const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH) + const paths = options.paths?.length ? options.paths : ["."] + const searchPath = paths[0] || "." + + const escapedPath = searchPath.replace(/'/g, "''") + const escapedPattern = options.pattern.replace(/'/g, "''") + + let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'` + + if (options.hidden) { + psCommand += " -Force" + } + + psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName" + + return ["powershell", "-NoProfile", "-Command", psCommand] +} + async function getFileMtime(filePath: string): Promise { try { const stats = await stat(filePath) @@ -49,25 +74,40 @@ async function getFileMtime(filePath: string): Promise { } } -export async function runRgFiles(options: GlobOptions): Promise { - const cli = resolveGrepCli() +export async function runRgFiles( + options: GlobOptions, + resolvedCli?: ResolvedCli +): Promise { + const cli = resolvedCli ?? resolveGrepCli() const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT) const isRg = cli.backend === "rg" - const args = isRg ? buildRgArgs(options) : buildFindArgs(options) + const isWindows = process.platform === "win32" + + let command: string[] + let cwd: string | undefined - const paths = options.paths?.length ? options.paths : ["."] if (isRg) { + const args = buildRgArgs(options) + const paths = options.paths?.length ? options.paths : ["."] args.push(...paths) + command = [cli.path, ...args] + cwd = undefined + } else if (isWindows) { + command = buildPowerShellCommand(options) + cwd = undefined + } else { + const args = buildFindArgs(options) + const paths = options.paths?.length ? options.paths : ["."] + cwd = paths[0] || "." + command = [cli.path, ...args] } - const cwd = paths[0] || "." - - const proc = spawn([cli.path, ...args], { + const proc = spawn(command, { stdout: "pipe", stderr: "pipe", - cwd: isRg ? undefined : cwd, + cwd, }) const timeoutPromise = new Promise((_, reject) => { @@ -106,7 +146,15 @@ export async function runRgFiles(options: GlobOptions): Promise { break } - const filePath = isRg ? line : `${cwd}/${line}` + let filePath: string + if (isRg) { + filePath = line + } else if (isWindows) { + filePath = line.trim() + } else { + filePath = `${cwd}/${line}` + } + const mtime = await getFileMtime(filePath) files.push({ path: filePath, mtime }) } diff --git a/src/tools/glob/constants.ts b/src/tools/glob/constants.ts index 38623e7..bc86efc 100644 --- a/src/tools/glob/constants.ts +++ b/src/tools/glob/constants.ts @@ -1,4 +1,4 @@ -export { resolveGrepCli, type GrepBackend } from "../grep/constants" +export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend } from "../grep/constants" export const DEFAULT_TIMEOUT_MS = 60_000 export const DEFAULT_LIMIT = 100 diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index e953676..e760827 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -1,5 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" +import { resolveGrepCliWithAutoInstall } from "./constants" import { formatGlobResult } from "./utils" export const glob: ToolDefinition = tool({ @@ -21,12 +22,16 @@ export const glob: ToolDefinition = tool({ }, execute: async (args) => { try { + const cli = await resolveGrepCliWithAutoInstall() const paths = args.path ? [args.path] : undefined - const result = await runRgFiles({ - pattern: args.pattern, - paths, - }) + const result = await runRgFiles( + { + pattern: args.pattern, + paths, + }, + cli + ) return formatGlobResult(result) } catch (e) { diff --git a/src/tools/grep/constants.ts b/src/tools/grep/constants.ts index b87fa80..df855d2 100644 --- a/src/tools/grep/constants.ts +++ b/src/tools/grep/constants.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs" import { join, dirname } from "node:path" import { spawnSync } from "node:child_process" import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader" +import { getDataDir } from "../../shared/data-path" export type GrepBackend = "rg" | "grep" @@ -36,6 +37,9 @@ function getOpenCodeBundledRg(): string | null { const rgName = isWindows ? "rg.exe" : "rg" const candidates = [ + // OpenCode XDG data path (highest priority - where OpenCode installs rg) + join(getDataDir(), "opencode", "bin", rgName), + // Legacy paths relative to execPath join(execDir, rgName), join(execDir, "bin", rgName), join(execDir, "..", "bin", rgName),