From e6f84f713b09156885556faf615befd9c89c33bc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 18 Apr 2026 02:37:57 +0900 Subject: [PATCH] refactor(tools): break glob->grep sibling-tool coupling Hoist shared ripgrep CLI resolution helpers (resolveGrepCli, resolveGrepCliWithAutoInstall, GrepBackend, DEFAULT_RG_THREADS, ResolvedCli) out of src/tools/grep/constants.ts into src/shared/ripgrep-cli.ts so they no longer straddle two sibling tool directories. Before: src/tools/glob/constants.ts re-exported from src/tools/grep/constants.ts, violating the project's "tools should not import from sibling tools" rule enforced by .sisyphus/rules/modular-code-enforcement.md. After: both src/tools/glob/ and src/tools/grep/ consume the shared helpers from src/shared/ripgrep-cli.ts. src/tools/grep/constants.ts keeps only the grep-specific UI-exposed constants. --- src/shared/ripgrep-cli.ts | 124 ++++++++++++++++++++++++++++++++++++ src/tools/glob/constants.ts | 2 +- src/tools/grep/cli.ts | 4 +- src/tools/grep/constants.ts | 124 ------------------------------------ src/tools/grep/tools.ts | 2 +- 5 files changed, 129 insertions(+), 127 deletions(-) create mode 100644 src/shared/ripgrep-cli.ts diff --git a/src/shared/ripgrep-cli.ts b/src/shared/ripgrep-cli.ts new file mode 100644 index 000000000..38eaff703 --- /dev/null +++ b/src/shared/ripgrep-cli.ts @@ -0,0 +1,124 @@ +import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" +import { dirname, join } from "node:path" +import { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "../tools/grep/downloader" +import { getDataDir } from "./data-path" +import { log } from "./logger" +import { PUBLISHED_PACKAGE_NAME } from "./plugin-identity" + +export type GrepBackend = "rg" | "grep" + +export interface ResolvedCli { + path: string + backend: GrepBackend +} + +export const DEFAULT_RG_THREADS = 4 + +let cachedCli: ResolvedCli | null = null +let autoInstallAttempted = false + +function findExecutable(name: string): string | null { + const isWindows = process.platform === "win32" + const cmd = isWindows ? "where" : "which" + + try { + const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 }) + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim().split("\n")[0] + } + } catch { + // Command execution failed + } + return null +} + +function getOpenCodeBundledRg(): string | null { + const execPath = process.execPath + const execDir = dirname(execPath) + + const isWindows = process.platform === "win32" + const rgName = isWindows ? "rg.exe" : "rg" + + const candidates = [ + join(getDataDir(), "opencode", "bin", rgName), + join(execDir, rgName), + join(execDir, "bin", rgName), + join(execDir, "..", "bin", rgName), + join(execDir, "..", "libexec", rgName), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +export function resolveGrepCli(): ResolvedCli { + if (cachedCli) { + return cachedCli + } + + const bundledRg = getOpenCodeBundledRg() + if (bundledRg) { + cachedCli = { path: bundledRg, backend: "rg" } + return cachedCli + } + + const systemRg = findExecutable("rg") + if (systemRg) { + cachedCli = { path: systemRg, backend: "rg" } + return cachedCli + } + + const installedRg = getInstalledRipgrepPath() + if (installedRg) { + cachedCli = { path: installedRg, backend: "rg" } + return cachedCli + } + + const grep = findExecutable("grep") + if (grep) { + cachedCli = { path: grep, backend: "grep" } + return cachedCli + } + + cachedCli = { path: "rg", backend: "rg" } + return cachedCli +} + +export async function resolveGrepCliWithAutoInstall(): Promise { + const current = resolveGrepCli() + + if (current.backend === "rg" && current.path !== "rg") { + return current + } + + if (autoInstallAttempted) { + return current + } + + autoInstallAttempted = true + + try { + const rgPath = await downloadAndInstallRipgrep() + cachedCli = { path: rgPath, backend: "rg" } + return cachedCli + } catch (error) { + if (current.backend === "grep") { + log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, { + error: error instanceof Error ? error.message : String(error), + grep_path: current.path, + }) + } else { + log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, { + error: error instanceof Error ? error.message : String(error), + }) + } + + return current + } +} diff --git a/src/tools/glob/constants.ts b/src/tools/glob/constants.ts index 05b5f85f1..8284b3681 100644 --- a/src/tools/glob/constants.ts +++ b/src/tools/glob/constants.ts @@ -1,4 +1,4 @@ -export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants" +export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../../shared/ripgrep-cli" export const DEFAULT_TIMEOUT_MS = 60_000 export const DEFAULT_LIMIT = 100 diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index bcec98aaa..9f55b1d27 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -3,13 +3,15 @@ import { resolveGrepCli, type ResolvedCli, type GrepBackend, + DEFAULT_RG_THREADS, +} from "../../shared/ripgrep-cli" +import { DEFAULT_MAX_DEPTH, DEFAULT_MAX_FILESIZE, DEFAULT_MAX_COUNT, DEFAULT_MAX_COLUMNS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, - DEFAULT_RG_THREADS, RG_SAFETY_FLAGS, GREP_SAFETY_FLAGS, } from "./constants" diff --git a/src/tools/grep/constants.ts b/src/tools/grep/constants.ts index 79db24c6b..f6c913303 100644 --- a/src/tools/grep/constants.ts +++ b/src/tools/grep/constants.ts @@ -1,126 +1,3 @@ -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" -import { log } from "../../shared/logger" -import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity" - -export type GrepBackend = "rg" | "grep" - -export interface ResolvedCli { - path: string - backend: GrepBackend -} - -let cachedCli: ResolvedCli | null = null -let autoInstallAttempted = false - -function findExecutable(name: string): string | null { - const isWindows = process.platform === "win32" - const cmd = isWindows ? "where" : "which" - - try { - const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 }) - if (result.status === 0 && result.stdout.trim()) { - return result.stdout.trim().split("\n")[0] - } - } catch { - // Command execution failed - } - return null -} - -function getOpenCodeBundledRg(): string | null { - const execPath = process.execPath - const execDir = dirname(execPath) - - const isWindows = process.platform === "win32" - 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), - join(execDir, "..", "libexec", rgName), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - return null -} - -export function resolveGrepCli(): ResolvedCli { - if (cachedCli) return cachedCli - - const bundledRg = getOpenCodeBundledRg() - if (bundledRg) { - cachedCli = { path: bundledRg, backend: "rg" } - return cachedCli - } - - const systemRg = findExecutable("rg") - if (systemRg) { - cachedCli = { path: systemRg, backend: "rg" } - return cachedCli - } - - const installedRg = getInstalledRipgrepPath() - if (installedRg) { - cachedCli = { path: installedRg, backend: "rg" } - return cachedCli - } - - const grep = findExecutable("grep") - if (grep) { - cachedCli = { path: grep, backend: "grep" } - return cachedCli - } - - cachedCli = { path: "rg", backend: "rg" } - return cachedCli -} - -export async function resolveGrepCliWithAutoInstall(): Promise { - const current = resolveGrepCli() - - if (current.backend === "rg" && current.path !== "rg") { - return current - } - - if (autoInstallAttempted) { - return current - } - - autoInstallAttempted = true - - try { - const rgPath = await downloadAndInstallRipgrep() - cachedCli = { path: rgPath, backend: "rg" } - return cachedCli - } catch (error) { - if (current.backend === "grep") { - log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, { - error: error instanceof Error ? error.message : String(error), - grep_path: current.path, - }) - } else { - log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, { - error: error instanceof Error ? error.message : String(error), - }) - } - - return current - } -} - export const DEFAULT_MAX_DEPTH = 20 export const DEFAULT_MAX_FILESIZE = "10M" export const DEFAULT_MAX_COUNT = 500 @@ -128,7 +5,6 @@ export const DEFAULT_MAX_COLUMNS = 1000 export const DEFAULT_CONTEXT = 2 export const DEFAULT_TIMEOUT_MS = 60_000 export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024 -export const DEFAULT_RG_THREADS = 4 export const RG_SAFETY_FLAGS = [ "--no-follow", diff --git a/src/tools/grep/tools.ts b/src/tools/grep/tools.ts index eaf8a3972..c40193c56 100644 --- a/src/tools/grep/tools.ts +++ b/src/tools/grep/tools.ts @@ -1,8 +1,8 @@ import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { resolveGrepCliWithAutoInstall } from "../../shared/ripgrep-cli" import { runRg, runRgCount } from "./cli" -import { resolveGrepCliWithAutoInstall } from "./constants" import { formatGrepResult, formatCountResult } from "./result-formatter" export function createGrepTools(ctx: PluginInput): Record {