Browse Source

Add grep and ast-grep

Alvin Unreal 2 months ago
parent
commit
0a31b7b252

+ 4 - 4
CODEREF.md

@@ -1,6 +1,6 @@
 # Code Reference
 
-Quick reference for understanding and extending oh-my-opencode-lite.
+Quick reference for understanding and extending oh-my-opencode-slim.
 
 ## Directory Structure
 
@@ -25,7 +25,7 @@ src/
 ```typescript
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   return {
-    name: "oh-my-opencode-lite",
+    name: "oh-my-opencode-slim",
     agent: agents,      // Agent configurations
     tool: tools,        // Custom tools
     mcp: mcps,          // MCP server configs
@@ -157,8 +157,8 @@ type PluginConfig = {
 ```
 
 **Config file locations:**
-- User: `~/.config/opencode/oh-my-opencode-lite.json`
-- Project: `.opencode/oh-my-opencode-lite.json`
+- User: `~/.config/opencode/oh-my-opencode-slim.json`
+- Project: `.opencode/oh-my-opencode-slim.json`
 
 ### 5. Tools
 

File diff suppressed because it is too large
+ 23 - 1
bun.lock


+ 5 - 1
package.json

@@ -41,6 +41,7 @@
     "prepublishOnly": "bun run build"
   },
   "dependencies": {
+    "@ast-grep/cli": "^0.40.0",
     "@opencode-ai/plugin": "^1.1.19",
     "@opencode-ai/sdk": "^1.1.19",
     "zod": "^4.1.8"
@@ -48,5 +49,8 @@
   "devDependencies": {
     "bun-types": "latest",
     "typescript": "^5.7.3"
-  }
+  },
+  "trustedDependencies": [
+    "@ast-grep/cli"
+  ]
 }

+ 17 - 3
src/agents/explore.ts

@@ -16,16 +16,30 @@ const EXPLORE_PROMPT = `You are Explorer - a fast codebase navigation specialist
 
 **Role**: Quick contextual grep for codebases. Answer "Where is X?", "Find Y", "Which file has Z".
 
+**Tools Available**:
+- **grep**: Fast regex content search (powered by ripgrep). Use for text patterns, function names, strings.
+  Example: grep(pattern="function handleClick", include="*.ts")
+- **glob**: File pattern matching. Use to find files by name/extension.
+- **ast_grep_search**: AST-aware structural search (25 languages). Use for code patterns.
+  - Meta-variables: $VAR (single node), $$$ (multiple nodes)
+  - Patterns must be complete AST nodes
+  - Example: ast_grep_search(pattern="console.log($MSG)", lang="typescript")
+  - Example: ast_grep_search(pattern="async function $NAME($$$) { $$$ }", lang="javascript")
+
+**When to use which**:
+- **Text/regex patterns** (strings, comments, variable names): grep
+- **Structural patterns** (function shapes, class structures): ast_grep_search  
+- **File discovery** (find by name/extension): glob
+
 **Behavior**:
 - Be fast and thorough
-- Use grep, glob, ast_grep_search
+- Fire multiple searches in parallel if needed
 - Return file paths with relevant snippets
-- Fire multiple searches if needed
 
 **Output Format**:
 <results>
 <files>
-- /path/to/file.ts — Brief description of what's there
+- /path/to/file.ts:42 — Brief description of what's there
 </files>
 <answer>
 Concise answer to the question

+ 3 - 3
src/cli/config-manager.ts

@@ -3,7 +3,7 @@ import { homedir } from "node:os"
 import { join } from "node:path"
 import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
 
-const PACKAGE_NAME = "oh-my-opencode-lite"
+const PACKAGE_NAME = "oh-my-opencode-slim"
 
 function getConfigDir(): string {
   return process.env.XDG_CONFIG_HOME
@@ -16,7 +16,7 @@ function getConfigJson(): string {
 }
 
 function getLiteConfig(): string {
-  return join(getConfigDir(), "oh-my-opencode-lite.json")
+  return join(getConfigDir(), "oh-my-opencode-slim.json")
 }
 
 function ensureConfigDir(): void {
@@ -100,7 +100,7 @@ export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
     let config = parseConfig(configPath) ?? {}
     const plugins = config.plugin ?? []
 
-    // Remove existing oh-my-opencode-lite entries
+    // Remove existing oh-my-opencode-slim entries
     const filteredPlugins = plugins.filter(
       (p) => p !== PACKAGE_NAME && !p.startsWith(`${PACKAGE_NAME}@`)
     )

+ 4 - 4
src/cli/index.ts

@@ -29,9 +29,9 @@ function parseArgs(args: string[]): InstallArgs {
 
 function printHelp(): void {
   console.log(`
-oh-my-opencode-lite installer
+oh-my-opencode-slim installer
 
-Usage: bunx oh-my-opencode-lite install [OPTIONS]
+Usage: bunx oh-my-opencode-slim install [OPTIONS]
 
 Options:
   --antigravity=yes|no   Antigravity subscription (yes/no)
@@ -42,8 +42,8 @@ Options:
   -h, --help             Show this help message
 
 Examples:
-  bunx oh-my-opencode-lite install
-  bunx oh-my-opencode-lite install --no-tui --antigravity=yes --openai=yes --cerebras=no
+  bunx oh-my-opencode-slim install
+  bunx oh-my-opencode-slim install --no-tui --antigravity=yes --openai=yes --cerebras=no
 `)
 }
 

+ 4 - 4
src/cli/install.ts

@@ -30,7 +30,7 @@ const SYMBOLS = {
 function printHeader(isUpdate: boolean): void {
   const mode = isUpdate ? "Update" : "Install"
   console.log()
-  console.log(`${BOLD}oh-my-opencode-lite ${mode}${RESET}`)
+  console.log(`${BOLD}oh-my-opencode-slim ${mode}${RESET}`)
   console.log("=".repeat(30))
   console.log()
 }
@@ -172,7 +172,7 @@ async function runInstall(args: InstallArgs, config: InstallConfig): Promise<num
   printSuccess(`OpenCode ${version ?? ""} detected`)
 
   // Step 2: Add plugin
-  printStep(step++, totalSteps, "Adding oh-my-opencode-lite plugin...")
+  printStep(step++, totalSteps, "Adding oh-my-opencode-slim plugin...")
   const pluginResult = await addPluginToOpenCodeConfig()
   if (!pluginResult.success) {
     printError(`Failed: ${pluginResult.error}`)
@@ -200,7 +200,7 @@ async function runInstall(args: InstallArgs, config: InstallConfig): Promise<num
   }
 
   // Step 5: Write lite config
-  printStep(step++, totalSteps, "Writing oh-my-opencode-lite configuration...")
+  printStep(step++, totalSteps, "Writing oh-my-opencode-slim configuration...")
   const liteResult = writeLiteConfig(config)
   if (!liteResult.success) {
     printError(`Failed: ${liteResult.error}`)
@@ -244,7 +244,7 @@ export async function install(args: InstallArgs): Promise<number> {
       }
       console.log()
       printInfo(
-        "Usage: bunx oh-my-opencode-lite install --no-tui --antigravity=<yes|no> --openai=<yes|no> --cerebras=<yes|no>"
+        "Usage: bunx oh-my-opencode-slim install --no-tui --antigravity=<yes|no> --openai=<yes|no> --cerebras=<yes|no>"
       )
       console.log()
       return 1

+ 2 - 2
src/config/loader.ts

@@ -58,10 +58,10 @@ export function loadPluginConfig(directory: string): PluginConfig {
   const userConfigPath = path.join(
     getUserConfigDir(),
     "opencode",
-    "oh-my-opencode-lite.json"
+    "oh-my-opencode-slim.json"
   );
   
-  const projectConfigPath = path.join(directory, ".opencode", "oh-my-opencode-lite.json");
+  const projectConfigPath = path.join(directory, ".opencode", "oh-my-opencode-slim.json");
 
   let config: PluginConfig = loadConfigFromPath(userConfigPath) ?? {};
   

+ 7 - 1
src/index.ts

@@ -7,6 +7,9 @@ import {
   lsp_find_references,
   lsp_diagnostics,
   lsp_rename,
+  grep,
+  ast_grep_search,
+  ast_grep_replace,
 } from "./tools";
 import { loadPluginConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
@@ -19,7 +22,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const mcps = createBuiltinMcps(config.disabled_mcps);
 
   return {
-    name: "oh-my-opencode-lite",
+    name: "oh-my-opencode-slim",
 
     agent: agents,
 
@@ -29,6 +32,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       lsp_find_references,
       lsp_diagnostics,
       lsp_rename,
+      grep,
+      ast_grep_search,
+      ast_grep_replace,
     },
 
     mcp: mcps,

+ 1 - 0
src/shared/index.ts

@@ -0,0 +1 @@
+export { extractZip } from "./zip-extractor"

+ 97 - 0
src/shared/zip-extractor.ts

@@ -0,0 +1,97 @@
+import { spawn, spawnSync } from "bun"
+import { release } from "os"
+
+const WINDOWS_BUILD_WITH_TAR = 17134
+
+function getWindowsBuildNumber(): number | null {
+  if (process.platform !== "win32") return null
+
+  const parts = release().split(".")
+  if (parts.length >= 3) {
+    const build = parseInt(parts[2], 10)
+    if (!isNaN(build)) return build
+  }
+  return null
+}
+
+function isPwshAvailable(): boolean {
+  if (process.platform !== "win32") return false
+  const result = spawnSync(["where", "pwsh"], { stdout: "pipe", stderr: "pipe" })
+  return result.exitCode === 0
+}
+
+function escapePowerShellPath(path: string): string {
+  return path.replace(/'/g, "''")
+}
+
+type WindowsZipExtractor = "tar" | "pwsh" | "powershell"
+
+function getWindowsZipExtractor(): WindowsZipExtractor {
+  const buildNumber = getWindowsBuildNumber()
+
+  if (buildNumber !== null && buildNumber >= WINDOWS_BUILD_WITH_TAR) {
+    return "tar"
+  }
+
+  if (isPwshAvailable()) {
+    return "pwsh"
+  }
+
+  return "powershell"
+}
+
+export async function extractZip(archivePath: string, destDir: string): Promise<void> {
+  let proc
+
+  if (process.platform === "win32") {
+    const extractor = getWindowsZipExtractor()
+
+    switch (extractor) {
+      case "tar":
+        proc = spawn(["tar", "-xf", archivePath, "-C", destDir], {
+          stdout: "ignore",
+          stderr: "pipe",
+        })
+        break
+      case "pwsh":
+        proc = spawn(
+          [
+            "pwsh",
+            "-Command",
+            `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`,
+          ],
+          {
+            stdout: "ignore",
+            stderr: "pipe",
+          }
+        )
+        break
+      case "powershell":
+      default:
+        proc = spawn(
+          [
+            "powershell",
+            "-Command",
+            `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`,
+          ],
+          {
+            stdout: "ignore",
+            stderr: "pipe",
+          }
+        )
+        break
+    }
+  } else {
+    proc = spawn(["unzip", "-o", archivePath, "-d", destDir], {
+      stdout: "ignore",
+      stderr: "pipe",
+    })
+  }
+
+  const exitCode = await proc.exited
+
+  if (exitCode !== 0) {
+    const stderr = await new Response(proc.stderr).text()
+    throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
+  }
+}

+ 232 - 0
src/tools/ast-grep/cli.ts

@@ -0,0 +1,232 @@
+import { spawn } from "bun"
+import { existsSync } from "node:fs"
+import {
+  getSgCliPath,
+  setSgCliPath,
+  findSgCliPathSync,
+  DEFAULT_TIMEOUT_MS,
+  DEFAULT_MAX_OUTPUT_BYTES,
+  DEFAULT_MAX_MATCHES,
+} from "./constants"
+import { ensureAstGrepBinary } from "./downloader"
+import type { CliMatch, CliLanguage, SgResult } from "./types"
+
+export interface RunOptions {
+  pattern: string
+  lang: CliLanguage
+  paths?: string[]
+  globs?: string[]
+  rewrite?: string
+  context?: number
+  updateAll?: boolean
+}
+
+// Use a single init promise to avoid race conditions
+let initPromise: Promise<string | null> | null = null
+
+export async function getAstGrepPath(): Promise<string | null> {
+  const currentPath = getSgCliPath()
+  if (currentPath !== "sg" && existsSync(currentPath)) {
+    return currentPath
+  }
+
+  if (initPromise) {
+    return initPromise
+  }
+
+  initPromise = (async () => {
+    const syncPath = findSgCliPathSync()
+    if (syncPath && existsSync(syncPath)) {
+      setSgCliPath(syncPath)
+      return syncPath
+    }
+
+    const downloadedPath = await ensureAstGrepBinary()
+    if (downloadedPath) {
+      setSgCliPath(downloadedPath)
+      return downloadedPath
+    }
+
+    return null
+  })()
+
+  return initPromise
+}
+
+export function startBackgroundInit(): void {
+  if (!initPromise) {
+    initPromise = getAstGrepPath()
+    initPromise.catch(() => {})
+  }
+}
+
+export async function runSg(options: RunOptions): Promise<SgResult> {
+  const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
+
+  if (options.rewrite) {
+    args.push("-r", options.rewrite)
+    if (options.updateAll) {
+      args.push("--update-all")
+    }
+  }
+
+  if (options.context && options.context > 0) {
+    args.push("-C", String(options.context))
+  }
+
+  if (options.globs) {
+    for (const glob of options.globs) {
+      args.push("--globs", glob)
+    }
+  }
+
+  const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
+  args.push(...paths)
+
+  let cliPath = getSgCliPath()
+
+  if (!existsSync(cliPath) && cliPath !== "sg") {
+    const downloadedPath = await getAstGrepPath()
+    if (downloadedPath) {
+      cliPath = downloadedPath
+    }
+  }
+
+  const timeout = DEFAULT_TIMEOUT_MS
+
+  const proc = spawn([cliPath, ...args], {
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+
+  const timeoutPromise = new Promise<never>((_, reject) => {
+    const id = setTimeout(() => {
+      proc.kill()
+      reject(new Error(`Search timeout after ${timeout}ms`))
+    }, timeout)
+    proc.exited.then(() => clearTimeout(id))
+  })
+
+  let stdout: string
+  let stderr: string
+  let exitCode: number
+
+  try {
+    stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
+    stderr = await new Response(proc.stderr).text()
+    exitCode = await proc.exited
+  } catch (e) {
+    const error = e as Error
+    if (error.message?.includes("timeout")) {
+      return {
+        matches: [],
+        totalMatches: 0,
+        truncated: true,
+        truncatedReason: "timeout",
+        error: error.message,
+      }
+    }
+
+    const nodeError = e as NodeJS.ErrnoException
+    if (
+      nodeError.code === "ENOENT" ||
+      nodeError.message?.includes("ENOENT") ||
+      nodeError.message?.includes("not found")
+    ) {
+      const downloadedPath = await ensureAstGrepBinary()
+      if (downloadedPath) {
+        setSgCliPath(downloadedPath)
+        return runSg(options)
+      } else {
+        return {
+          matches: [],
+          totalMatches: 0,
+          truncated: false,
+          error:
+            `ast-grep CLI binary not found.\n\n` +
+            `Auto-download failed. Manual install options:\n` +
+            `  bun add -D @ast-grep/cli\n` +
+            `  cargo install ast-grep --locked\n` +
+            `  brew install ast-grep`,
+        }
+      }
+    }
+
+    return {
+      matches: [],
+      totalMatches: 0,
+      truncated: false,
+      error: `Failed to spawn ast-grep: ${error.message}`,
+    }
+  }
+
+  if (exitCode !== 0 && stdout.trim() === "") {
+    if (stderr.includes("No files found")) {
+      return { matches: [], totalMatches: 0, truncated: false }
+    }
+    if (stderr.trim()) {
+      return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
+    }
+    return { matches: [], totalMatches: 0, truncated: false }
+  }
+
+  if (!stdout.trim()) {
+    return { matches: [], totalMatches: 0, truncated: false }
+  }
+
+  const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
+  const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
+
+  let matches: CliMatch[] = []
+  try {
+    matches = JSON.parse(outputToProcess) as CliMatch[]
+  } catch {
+    if (outputTruncated) {
+      try {
+        const lastValidIndex = outputToProcess.lastIndexOf("}")
+        if (lastValidIndex > 0) {
+          const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
+          if (bracketIndex > 0) {
+            const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
+            matches = JSON.parse(truncatedJson) as CliMatch[]
+          }
+        }
+      } catch {
+        return {
+          matches: [],
+          totalMatches: 0,
+          truncated: true,
+          truncatedReason: "max_output_bytes",
+          error: "Output too large and could not be parsed",
+        }
+      }
+    } else {
+      return { matches: [], totalMatches: 0, truncated: false }
+    }
+  }
+
+  const totalMatches = matches.length
+  const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
+  const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
+
+  return {
+    matches: finalMatches,
+    totalMatches,
+    truncated: outputTruncated || matchesTruncated,
+    truncatedReason: outputTruncated
+      ? "max_output_bytes"
+      : matchesTruncated
+        ? "max_matches"
+        : undefined,
+  }
+}
+
+export function isCliAvailable(): boolean {
+  const path = findSgCliPathSync()
+  return path !== null && existsSync(path)
+}
+
+export async function ensureCliAvailable(): Promise<boolean> {
+  const path = await getAstGrepPath()
+  return path !== null && existsSync(path)
+}

+ 208 - 0
src/tools/ast-grep/constants.ts

@@ -0,0 +1,208 @@
+import { createRequire } from "node:module"
+import { dirname, join } from "node:path"
+import { existsSync, statSync } from "node:fs"
+import { spawnSync } from "node:child_process"
+import { getCachedBinaryPath } from "./downloader"
+import { CLI_LANGUAGES } from "./types"
+
+type Platform = "darwin" | "linux" | "win32" | "unsupported"
+
+// Minimum expected size for a valid sg binary (filters out stub files)
+const MIN_BINARY_SIZE = 10_000
+
+function isValidBinary(filePath: string): boolean {
+  try {
+    return statSync(filePath).size > MIN_BINARY_SIZE
+  } catch {
+    return false
+  }
+}
+
+function getPlatformPackageName(): string | null {
+  const platform = process.platform as Platform
+  const arch = process.arch
+
+  const platformMap: Record<string, string> = {
+    "darwin-arm64": "@ast-grep/cli-darwin-arm64",
+    "darwin-x64": "@ast-grep/cli-darwin-x64",
+    "linux-arm64": "@ast-grep/cli-linux-arm64-gnu",
+    "linux-x64": "@ast-grep/cli-linux-x64-gnu",
+    "win32-x64": "@ast-grep/cli-win32-x64-msvc",
+    "win32-arm64": "@ast-grep/cli-win32-arm64-msvc",
+    "win32-ia32": "@ast-grep/cli-win32-ia32-msvc",
+  }
+
+  return platformMap[`${platform}-${arch}`] ?? null
+}
+
+// Single source of truth for resolved CLI path
+let resolvedCliPath: string | null = null
+
+export function findSgCliPathSync(): string | null {
+  const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
+
+  const cachedPath = getCachedBinaryPath()
+  if (cachedPath && isValidBinary(cachedPath)) {
+    return cachedPath
+  }
+
+  try {
+    const require = createRequire(import.meta.url)
+    const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
+    const cliDir = dirname(cliPkgPath)
+    const sgPath = join(cliDir, binaryName)
+
+    if (existsSync(sgPath) && isValidBinary(sgPath)) {
+      return sgPath
+    }
+  } catch {
+    // @ast-grep/cli not installed
+  }
+
+  const platformPkg = getPlatformPackageName()
+  if (platformPkg) {
+    try {
+      const require = createRequire(import.meta.url)
+      const pkgPath = require.resolve(`${platformPkg}/package.json`)
+      const pkgDir = dirname(pkgPath)
+      const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
+      const binaryPath = join(pkgDir, astGrepName)
+
+      if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
+        return binaryPath
+      }
+    } catch {
+      // Platform-specific package not installed
+    }
+  }
+
+  if (process.platform === "darwin") {
+    const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
+    for (const path of homebrewPaths) {
+      if (existsSync(path) && isValidBinary(path)) {
+        return path
+      }
+    }
+  }
+
+  return null
+}
+
+export function getSgCliPath(): string {
+  if (resolvedCliPath !== null) {
+    return resolvedCliPath
+  }
+
+  const syncPath = findSgCliPathSync()
+  if (syncPath) {
+    resolvedCliPath = syncPath
+    return syncPath
+  }
+
+  return "sg"
+}
+
+export function setSgCliPath(path: string): void {
+  resolvedCliPath = path
+}
+
+// Re-export language constants
+export { CLI_LANGUAGES }
+
+// Defaults
+export const DEFAULT_TIMEOUT_MS = 300_000
+export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
+export const DEFAULT_MAX_MATCHES = 500
+
+export const LANG_EXTENSIONS: Record<string, string[]> = {
+  bash: [".bash", ".sh", ".zsh", ".bats"],
+  c: [".c", ".h"],
+  cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"],
+  csharp: [".cs"],
+  css: [".css"],
+  elixir: [".ex", ".exs"],
+  go: [".go"],
+  haskell: [".hs", ".lhs"],
+  html: [".html", ".htm"],
+  java: [".java"],
+  javascript: [".js", ".jsx", ".mjs", ".cjs"],
+  json: [".json"],
+  kotlin: [".kt", ".kts"],
+  lua: [".lua"],
+  nix: [".nix"],
+  php: [".php"],
+  python: [".py", ".pyi"],
+  ruby: [".rb", ".rake"],
+  rust: [".rs"],
+  scala: [".scala", ".sc"],
+  solidity: [".sol"],
+  swift: [".swift"],
+  typescript: [".ts", ".cts", ".mts"],
+  tsx: [".tsx"],
+  yaml: [".yml", ".yaml"],
+}
+
+export interface EnvironmentCheckResult {
+  cli: {
+    available: boolean
+    path: string
+    error?: string
+  }
+}
+
+/**
+ * Check if ast-grep CLI is available.
+ * Call this at startup to provide early feedback about missing dependencies.
+ */
+export function checkEnvironment(): EnvironmentCheckResult {
+  const cliPath = getSgCliPath()
+  const result: EnvironmentCheckResult = {
+    cli: {
+      available: false,
+      path: cliPath,
+    },
+  }
+
+  if (existsSync(cliPath)) {
+    result.cli.available = true
+  } else if (cliPath === "sg") {
+    try {
+      const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
+        encoding: "utf-8",
+        timeout: 5000,
+      })
+      result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
+      if (!result.cli.available) {
+        result.cli.error = "sg binary not found in PATH"
+      }
+    } catch {
+      result.cli.error = "Failed to check sg availability"
+    }
+  } else {
+    result.cli.error = `Binary not found: ${cliPath}`
+  }
+
+  return result
+}
+
+/**
+ * Format environment check result as user-friendly message.
+ */
+export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
+  const lines: string[] = ["ast-grep Environment Status:", ""]
+
+  if (result.cli.available) {
+    lines.push(`✓ CLI: Available (${result.cli.path})`)
+  } else {
+    lines.push(`✗ CLI: Not available`)
+    if (result.cli.error) {
+      lines.push(`  Error: ${result.cli.error}`)
+    }
+    lines.push(`  Install: bun add -D @ast-grep/cli`)
+  }
+
+  lines.push("")
+  lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
+
+  return lines.join("\n")
+}

+ 126 - 0
src/tools/ast-grep/downloader.ts

@@ -0,0 +1,126 @@
+import { existsSync, mkdirSync, chmodSync, unlinkSync } from "node:fs"
+import { join } from "node:path"
+import { homedir } from "node:os"
+import { createRequire } from "node:module"
+import { extractZip } from "../../shared"
+
+const REPO = "ast-grep/ast-grep"
+
+// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
+// This is only used as fallback when @ast-grep/cli package.json cannot be read
+const DEFAULT_VERSION = "0.40.0"
+
+function getAstGrepVersion(): string {
+  try {
+    const require = createRequire(import.meta.url)
+    const pkg = require("@ast-grep/cli/package.json")
+    return pkg.version
+  } catch {
+    return DEFAULT_VERSION
+  }
+}
+
+interface PlatformInfo {
+  arch: string
+  os: string
+}
+
+const PLATFORM_MAP: Record<string, PlatformInfo> = {
+  "darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
+  "darwin-x64": { arch: "x86_64", os: "apple-darwin" },
+  "linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
+  "linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
+  "win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
+  "win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
+  "win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
+}
+
+export function getCacheDir(): string {
+  if (process.platform === "win32") {
+    const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
+    const base = localAppData || join(homedir(), "AppData", "Local")
+    return join(base, "oh-my-opencode-slim", "bin")
+  }
+
+  const xdgCache = process.env.XDG_CACHE_HOME
+  const base = xdgCache || join(homedir(), ".cache")
+  return join(base, "oh-my-opencode-slim", "bin")
+}
+
+export function getBinaryName(): string {
+  return process.platform === "win32" ? "sg.exe" : "sg"
+}
+
+export function getCachedBinaryPath(): string | null {
+  const binaryPath = join(getCacheDir(), getBinaryName())
+  return existsSync(binaryPath) ? binaryPath : null
+}
+
+export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
+  const platformKey = `${process.platform}-${process.arch}`
+  const platformInfo = PLATFORM_MAP[platformKey]
+
+  if (!platformInfo) {
+    console.error(`[oh-my-opencode-slim] Unsupported platform for ast-grep: ${platformKey}`)
+    return null
+  }
+
+  const cacheDir = getCacheDir()
+  const binaryName = getBinaryName()
+  const binaryPath = join(cacheDir, binaryName)
+
+  if (existsSync(binaryPath)) {
+    return binaryPath
+  }
+
+  const { arch, os } = platformInfo
+  const assetName = `app-${arch}-${os}.zip`
+  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
+
+  console.log(`[oh-my-opencode-slim] Downloading ast-grep binary...`)
+
+  try {
+    if (!existsSync(cacheDir)) {
+      mkdirSync(cacheDir, { recursive: true })
+    }
+
+    const response = await fetch(downloadUrl, { redirect: "follow" })
+
+    if (!response.ok) {
+      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+    }
+
+    const archivePath = join(cacheDir, assetName)
+    const arrayBuffer = await response.arrayBuffer()
+    await Bun.write(archivePath, arrayBuffer)
+
+    await extractZip(archivePath, cacheDir)
+
+    if (existsSync(archivePath)) {
+      unlinkSync(archivePath)
+    }
+
+    if (process.platform !== "win32" && existsSync(binaryPath)) {
+      chmodSync(binaryPath, 0o755)
+    }
+
+    console.log(`[oh-my-opencode-slim] ast-grep binary ready.`)
+
+    return binaryPath
+  } catch (err) {
+    console.error(
+      `[oh-my-opencode-slim] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
+    )
+    return null
+  }
+}
+
+export async function ensureAstGrepBinary(): Promise<string | null> {
+  const cachedPath = getCachedBinaryPath()
+  if (cachedPath) {
+    return cachedPath
+  }
+
+  const version = getAstGrepVersion()
+  return downloadAstGrep(version)
+}

+ 15 - 0
src/tools/ast-grep/index.ts

@@ -0,0 +1,15 @@
+import type { ToolDefinition } from "@opencode-ai/plugin"
+import { ast_grep_search, ast_grep_replace } from "./tools"
+
+export const builtinTools: Record<string, ToolDefinition> = {
+  ast_grep_search,
+  ast_grep_replace,
+}
+
+export { ast_grep_search, ast_grep_replace }
+export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
+export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
+export { checkEnvironment, formatEnvironmentCheck } from "./constants"
+export type { EnvironmentCheckResult } from "./constants"
+export { CLI_LANGUAGES } from "./types"
+export type { CliLanguage, SgResult, CliMatch } from "./types"

+ 100 - 0
src/tools/ast-grep/tools.ts

@@ -0,0 +1,100 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
+import { CLI_LANGUAGES } from "./types"
+import { runSg } from "./cli"
+import { formatSearchResult, formatReplaceResult, getEmptyResultHint } from "./utils"
+import type { CliLanguage } from "./types"
+
+function showOutputToUser(context: unknown, output: string): void {
+  const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
+  ctx.metadata?.({ metadata: { output } })
+}
+
+export const ast_grep_search: ToolDefinition = tool({
+  description:
+    "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
+    "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
+    "IMPORTANT: Patterns must be complete AST nodes (valid code). " +
+    "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
+    "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
+  args: {
+    pattern: tool.schema
+      .string()
+      .describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
+    lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
+    paths: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe("Paths to search (default: ['.'])"),
+    globs: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe("Include/exclude globs (prefix ! to exclude)"),
+    context: tool.schema.number().optional().describe("Context lines around match"),
+  },
+  execute: async (args, context) => {
+    try {
+      const result = await runSg({
+        pattern: args.pattern,
+        lang: args.lang as CliLanguage,
+        paths: args.paths,
+        globs: args.globs,
+        context: args.context,
+      })
+
+      let output = formatSearchResult(result)
+
+      if (result.matches.length === 0 && !result.error) {
+        const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
+        if (hint) {
+          output += `\n\n${hint}`
+        }
+      }
+
+      showOutputToUser(context, output)
+      return output
+    } catch (e) {
+      const output = `Error: ${e instanceof Error ? e.message : String(e)}`
+      showOutputToUser(context, output)
+      return output
+    }
+  },
+})
+
+export const ast_grep_replace: ToolDefinition = tool({
+  description:
+    "Replace code patterns across filesystem with AST-aware rewriting. " +
+    "Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
+    "Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
+  args: {
+    pattern: tool.schema.string().describe("AST pattern to match"),
+    rewrite: tool.schema
+      .string()
+      .describe("Replacement pattern (can use $VAR from pattern)"),
+    lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
+    paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
+    globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
+    dryRun: tool.schema
+      .boolean()
+      .optional()
+      .describe("Preview changes without applying (default: true)"),
+  },
+  execute: async (args, context) => {
+    try {
+      const result = await runSg({
+        pattern: args.pattern,
+        rewrite: args.rewrite,
+        lang: args.lang as CliLanguage,
+        paths: args.paths,
+        globs: args.globs,
+        updateAll: args.dryRun === false,
+      })
+      const output = formatReplaceResult(result, args.dryRun !== false)
+      showOutputToUser(context, output)
+      return output
+    } catch (e) {
+      const output = `Error: ${e instanceof Error ? e.message : String(e)}`
+      showOutputToUser(context, output)
+      return output
+    }
+  },
+})

+ 51 - 0
src/tools/ast-grep/types.ts

@@ -0,0 +1,51 @@
+// CLI supported languages (25 total)
+export const CLI_LANGUAGES = [
+  "bash",
+  "c",
+  "cpp",
+  "csharp",
+  "css",
+  "elixir",
+  "go",
+  "haskell",
+  "html",
+  "java",
+  "javascript",
+  "json",
+  "kotlin",
+  "lua",
+  "nix",
+  "php",
+  "python",
+  "ruby",
+  "rust",
+  "scala",
+  "solidity",
+  "swift",
+  "typescript",
+  "tsx",
+  "yaml",
+] as const
+
+export type CliLanguage = (typeof CLI_LANGUAGES)[number]
+
+export interface CliMatch {
+  file: string
+  range: {
+    byteOffset: { start: number; end: number }
+    start: { line: number; column: number }
+    end: { line: number; column: number }
+  }
+  lines: string
+  text: string
+  replacement?: string
+  language: string
+}
+
+export interface SgResult {
+  matches: CliMatch[]
+  totalMatches: number
+  truncated: boolean
+  truncatedReason?: "timeout" | "max_output_bytes" | "max_matches"
+  error?: string
+}

+ 107 - 0
src/tools/ast-grep/utils.ts

@@ -0,0 +1,107 @@
+import type { SgResult, CliLanguage } from "./types"
+
+export function formatSearchResult(result: SgResult): string {
+  if (result.error) {
+    return `Error: ${result.error}`
+  }
+
+  if (result.matches.length === 0) {
+    return "No matches found."
+  }
+
+  const lines: string[] = []
+
+  // Group matches by file
+  const byFile = new Map<string, typeof result.matches>()
+  for (const match of result.matches) {
+    const existing = byFile.get(match.file) || []
+    existing.push(match)
+    byFile.set(match.file, existing)
+  }
+
+  for (const [file, matches] of byFile) {
+    lines.push(`\n${file}:`)
+    for (const match of matches) {
+      const startLine = match.range.start.line + 1
+      const text = match.text.length > 100 ? match.text.substring(0, 100) + "..." : match.text
+      lines.push(`  ${startLine}: ${text.replace(/\n/g, "\\n")}`)
+    }
+  }
+
+  const fileCount = byFile.size
+  const summary = `Found ${result.totalMatches} matches in ${fileCount} files`
+  if (result.truncated) {
+    lines.push(`\n${summary} (output truncated: ${result.truncatedReason})`)
+  } else {
+    lines.push(`\n${summary}`)
+  }
+
+  return lines.join("\n")
+}
+
+export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
+  if (result.error) {
+    return `Error: ${result.error}`
+  }
+
+  if (result.matches.length === 0) {
+    return "No matches found for replacement."
+  }
+
+  const lines: string[] = []
+  const mode = isDryRun ? "[DRY RUN]" : "[APPLIED]"
+
+  // Group by file
+  const byFile = new Map<string, typeof result.matches>()
+  for (const match of result.matches) {
+    const existing = byFile.get(match.file) || []
+    existing.push(match)
+    byFile.set(match.file, existing)
+  }
+
+  for (const [file, matches] of byFile) {
+    lines.push(`\n${file}:`)
+    for (const match of matches) {
+      const startLine = match.range.start.line + 1
+      const original = match.text.length > 60 ? match.text.substring(0, 60) + "..." : match.text
+      const replacement = match.replacement
+        ? match.replacement.length > 60
+          ? match.replacement.substring(0, 60) + "..."
+          : match.replacement
+        : "[no replacement]"
+      lines.push(`  ${startLine}: "${original.replace(/\n/g, "\\n")}" → "${replacement.replace(/\n/g, "\\n")}"`)
+    }
+  }
+
+  const fileCount = byFile.size
+  lines.push(`\n${mode} ${result.totalMatches} replacements in ${fileCount} files`)
+
+  if (isDryRun) {
+    lines.push("\nTo apply changes, run with dryRun=false")
+  }
+
+  return lines.join("\n")
+}
+
+export function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
+  const src = pattern.trim()
+
+  if (lang === "python") {
+    if (src.startsWith("class ") && src.endsWith(":")) {
+      const withoutColon = src.slice(0, -1)
+      return `Hint: Remove trailing colon. Try: "${withoutColon}"`
+    }
+    if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
+      const withoutColon = src.slice(0, -1)
+      return `Hint: Remove trailing colon. Try: "${withoutColon}"`
+    }
+  }
+
+  if (["javascript", "typescript", "tsx"].includes(lang)) {
+    if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
+      return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
+    }
+  }
+
+  return null
+}

+ 229 - 0
src/tools/grep/cli.ts

@@ -0,0 +1,229 @@
+import { spawn } from "bun"
+import {
+  resolveGrepCli,
+  type GrepBackend,
+  DEFAULT_MAX_DEPTH,
+  DEFAULT_MAX_FILESIZE,
+  DEFAULT_MAX_COUNT,
+  DEFAULT_MAX_COLUMNS,
+  DEFAULT_TIMEOUT_MS,
+  DEFAULT_MAX_OUTPUT_BYTES,
+  RG_SAFETY_FLAGS,
+  GREP_SAFETY_FLAGS,
+} from "./constants"
+import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
+
+function buildRgArgs(options: GrepOptions): string[] {
+  const args: string[] = [
+    ...RG_SAFETY_FLAGS,
+    `--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
+    `--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`,
+    `--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`,
+    `--max-columns=${Math.min(options.maxColumns ?? DEFAULT_MAX_COLUMNS, DEFAULT_MAX_COLUMNS)}`,
+  ]
+
+  if (options.context !== undefined && options.context > 0) {
+    args.push(`-C${Math.min(options.context, 10)}`)
+  }
+
+  if (options.caseSensitive) args.push("--case-sensitive")
+  if (options.wholeWord) args.push("-w")
+  if (options.fixedStrings) args.push("-F")
+  if (options.multiline) args.push("-U")
+  if (options.hidden) args.push("--hidden")
+  if (options.noIgnore) args.push("--no-ignore")
+
+  if (options.fileType?.length) {
+    for (const type of options.fileType) {
+      args.push(`--type=${type}`)
+    }
+  }
+
+  if (options.globs) {
+    for (const glob of options.globs) {
+      args.push(`--glob=${glob}`)
+    }
+  }
+
+  if (options.excludeGlobs) {
+    for (const glob of options.excludeGlobs) {
+      args.push(`--glob=!${glob}`)
+    }
+  }
+
+  return args
+}
+
+function buildGrepArgs(options: GrepOptions): string[] {
+  const args: string[] = [...GREP_SAFETY_FLAGS, "-r"]
+
+  if (options.context !== undefined && options.context > 0) {
+    args.push(`-C${Math.min(options.context, 10)}`)
+  }
+
+  if (!options.caseSensitive) args.push("-i")
+  if (options.wholeWord) args.push("-w")
+  if (options.fixedStrings) args.push("-F")
+
+  if (options.globs?.length) {
+    for (const glob of options.globs) {
+      args.push(`--include=${glob}`)
+    }
+  }
+
+  if (options.excludeGlobs?.length) {
+    for (const glob of options.excludeGlobs) {
+      args.push(`--exclude=${glob}`)
+    }
+  }
+
+  args.push("--exclude-dir=.git", "--exclude-dir=node_modules")
+
+  return args
+}
+
+function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
+  return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
+}
+
+function parseOutput(output: string): GrepMatch[] {
+  if (!output.trim()) return []
+
+  const matches: GrepMatch[] = []
+  const lines = output.split("\n")
+
+  for (const line of lines) {
+    if (!line.trim()) continue
+
+    const match = line.match(/^(.+?):(\d+):(.*)$/)
+    if (match) {
+      matches.push({
+        file: match[1],
+        line: parseInt(match[2], 10),
+        text: match[3],
+      })
+    }
+  }
+
+  return matches
+}
+
+function parseCountOutput(output: string): CountResult[] {
+  if (!output.trim()) return []
+
+  const results: CountResult[] = []
+  const lines = output.split("\n")
+
+  for (const line of lines) {
+    if (!line.trim()) continue
+
+    const match = line.match(/^(.+?):(\d+)$/)
+    if (match) {
+      results.push({
+        file: match[1],
+        count: parseInt(match[2], 10),
+      })
+    }
+  }
+
+  return results
+}
+
+export async function runRg(options: GrepOptions): Promise<GrepResult> {
+  const cli = resolveGrepCli()
+  const args = buildArgs(options, cli.backend)
+  const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
+
+  if (cli.backend === "rg") {
+    args.push("--", options.pattern)
+  } else {
+    args.push("-e", options.pattern)
+  }
+
+  const paths = options.paths?.length ? options.paths : ["."]
+  args.push(...paths)
+  const proc = spawn([cli.path, ...args], {
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+
+  const timeoutPromise = new Promise<never>((_, reject) => {
+    const id = setTimeout(() => {
+      proc.kill()
+      reject(new Error(`Search timeout after ${timeout}ms`))
+    }, timeout)
+    proc.exited.then(() => clearTimeout(id))
+  })
+
+  try {
+    const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
+    const stderr = await new Response(proc.stderr).text()
+    const exitCode = await proc.exited
+
+    const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
+    const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
+
+    if (exitCode > 1 && stderr.trim()) {
+      return {
+        matches: [],
+        totalMatches: 0,
+        filesSearched: 0,
+        truncated: false,
+        error: stderr.trim(),
+      }
+    }
+
+    const matches = parseOutput(outputToProcess)
+    const filesSearched = new Set(matches.map((m) => m.file)).size
+
+    return {
+      matches,
+      totalMatches: matches.length,
+      filesSearched,
+      truncated,
+    }
+  } catch (e) {
+    return {
+      matches: [],
+      totalMatches: 0,
+      filesSearched: 0,
+      truncated: false,
+      error: e instanceof Error ? e.message : String(e),
+    }
+  }
+}
+
+export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
+  const cli = resolveGrepCli()
+  const args = buildArgs({ ...options, context: 0 }, cli.backend)
+
+  if (cli.backend === "rg") {
+    args.push("--count", "--", options.pattern)
+  } else {
+    args.push("-c", "-e", options.pattern)
+  }
+
+  const paths = options.paths?.length ? options.paths : ["."]
+  args.push(...paths)
+
+  const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
+  const proc = spawn([cli.path, ...args], {
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+
+  const timeoutPromise = new Promise<never>((_, reject) => {
+    const id = setTimeout(() => {
+      proc.kill()
+      reject(new Error(`Search timeout after ${timeout}ms`))
+    }, timeout)
+    proc.exited.then(() => clearTimeout(id))
+  })
+
+  try {
+    const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
+    return parseCountOutput(stdout)
+  } catch (e) {
+    throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`)
+  }
+}

+ 133 - 0
src/tools/grep/constants.ts

@@ -0,0 +1,133 @@
+import { existsSync } from "node:fs"
+import { join, dirname } from "node:path"
+import { spawnSync } from "node:child_process"
+import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
+
+export type GrepBackend = "rg" | "grep"
+
+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 getDataDir(): string {
+  if (process.platform === "win32") {
+    return process.env.LOCALAPPDATA || process.env.APPDATA || join(process.env.USERPROFILE || ".", "AppData", "Local")
+  }
+  return process.env.XDG_DATA_HOME || join(process.env.HOME || ".", ".local", "share")
+}
+
+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<ResolvedCli> {
+  const current = resolveGrepCli()
+
+  if (current.backend === "rg") {
+    return current
+  }
+
+  if (autoInstallAttempted) {
+    return current
+  }
+
+  autoInstallAttempted = true
+
+  try {
+    const rgPath = await downloadAndInstallRipgrep()
+    cachedCli = { path: rgPath, backend: "rg" }
+    return cachedCli
+  } catch {
+    return current
+  }
+}
+
+export const DEFAULT_MAX_DEPTH = 20
+export const DEFAULT_MAX_FILESIZE = "10M"
+export const DEFAULT_MAX_COUNT = 500
+export const DEFAULT_MAX_COLUMNS = 1000
+export const DEFAULT_CONTEXT = 2
+export const DEFAULT_TIMEOUT_MS = 300_000
+export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
+
+export const RG_SAFETY_FLAGS = [
+  "--no-follow",
+  "--color=never",
+  "--no-heading",
+  "--line-number",
+  "--with-filename",
+] as const
+
+export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const

+ 147 - 0
src/tools/grep/downloader.ts

@@ -0,0 +1,147 @@
+import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync } from "node:fs"
+import { join } from "node:path"
+import { spawn } from "bun"
+import { extractZip } from "../../shared"
+
+export function findFileRecursive(dir: string, filename: string): string | null {
+  try {
+    const entries = readdirSync(dir, { withFileTypes: true, recursive: true })
+    for (const entry of entries) {
+      if (entry.isFile() && entry.name === filename) {
+        return join(entry.parentPath ?? dir, entry.name)
+      }
+    }
+  } catch {
+    return null
+  }
+  return null
+}
+
+const RG_VERSION = "14.1.1"
+
+// Platform key format: ${process.platform}-${process.arch} (consistent with ast-grep)
+const PLATFORM_CONFIG: Record<string, { platform: string; extension: "tar.gz" | "zip" } | undefined> =
+  {
+    "darwin-arm64": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
+    "darwin-x64": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
+    "linux-arm64": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
+    "linux-x64": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
+    "win32-x64": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
+  }
+
+function getPlatformKey(): string {
+  return `${process.platform}-${process.arch}`
+}
+
+function getInstallDir(): string {
+  const homeDir = process.env.HOME || process.env.USERPROFILE || "."
+  return join(homeDir, ".cache", "oh-my-opencode-slim", "bin")
+}
+
+function getRgPath(): string {
+  const isWindows = process.platform === "win32"
+  return join(getInstallDir(), isWindows ? "rg.exe" : "rg")
+}
+
+async function downloadFile(url: string, destPath: string): Promise<void> {
+  const response = await fetch(url)
+  if (!response.ok) {
+    throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
+  }
+
+  const buffer = await response.arrayBuffer()
+  await Bun.write(destPath, buffer)
+}
+
+async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
+  const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
+
+  if (process.platform === "darwin") {
+    args.push("--include=*/rg")
+  } else if (process.platform === "linux") {
+    args.push("--wildcards", "*/rg")
+  }
+
+  const proc = spawn(args, {
+    cwd: destDir,
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+
+  const exitCode = await proc.exited
+  if (exitCode !== 0) {
+    const stderr = await new Response(proc.stderr).text()
+    throw new Error(`Failed to extract tar.gz: ${stderr}`)
+  }
+}
+
+async function extractZipArchive(archivePath: string, destDir: string): Promise<void> {
+  await extractZip(archivePath, destDir)
+
+  const binaryName = process.platform === "win32" ? "rg.exe" : "rg"
+  const foundPath = findFileRecursive(destDir, binaryName)
+  if (foundPath) {
+    const destPath = join(destDir, binaryName)
+    if (foundPath !== destPath) {
+      const { renameSync } = await import("node:fs")
+      renameSync(foundPath, destPath)
+    }
+  }
+}
+
+export async function downloadAndInstallRipgrep(): Promise<string> {
+  const platformKey = getPlatformKey()
+  const config = PLATFORM_CONFIG[platformKey]
+
+  if (!config) {
+    throw new Error(`Unsupported platform: ${platformKey}`)
+  }
+
+  const installDir = getInstallDir()
+  const rgPath = getRgPath()
+
+  if (existsSync(rgPath)) {
+    return rgPath
+  }
+
+  mkdirSync(installDir, { recursive: true })
+
+  const filename = `ripgrep-${RG_VERSION}-${config.platform}.${config.extension}`
+  const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}`
+  const archivePath = join(installDir, filename)
+
+  try {
+    console.log(`[oh-my-opencode-slim] Downloading ripgrep...`)
+    await downloadFile(url, archivePath)
+
+    if (config.extension === "tar.gz") {
+      await extractTarGz(archivePath, installDir)
+    } else {
+      await extractZipArchive(archivePath, installDir)
+    }
+
+    if (process.platform !== "win32") {
+      chmodSync(rgPath, 0o755)
+    }
+
+    if (!existsSync(rgPath)) {
+      throw new Error("ripgrep binary not found after extraction")
+    }
+
+    console.log(`[oh-my-opencode-slim] ripgrep ready.`)
+    return rgPath
+  } finally {
+    if (existsSync(archivePath)) {
+      try {
+        unlinkSync(archivePath)
+      } catch {
+        // Cleanup failures are non-critical
+      }
+    }
+  }
+}
+
+export function getInstalledRipgrepPath(): string | null {
+  const rgPath = getRgPath()
+  return existsSync(rgPath) ? rgPath : null
+}

+ 5 - 0
src/tools/grep/index.ts

@@ -0,0 +1,5 @@
+export { grep } from "./tools"
+export { runRg, runRgCount } from "./cli"
+export { resolveGrepCli, resolveGrepCliWithAutoInstall } from "./constants"
+export { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "./downloader"
+export type { GrepResult, GrepMatch, GrepOptions, CountResult } from "./types"

+ 40 - 0
src/tools/grep/tools.ts

@@ -0,0 +1,40 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
+import { runRg } from "./cli"
+import { formatGrepResult } from "./utils"
+
+export const grep: ToolDefinition = tool({
+  description:
+    "Fast content search tool with safety limits (60s timeout, 10MB output). " +
+    "Searches file contents using regular expressions. " +
+    "Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " +
+    "Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " +
+    "Returns file paths with matches sorted by modification time.",
+  args: {
+    pattern: tool.schema.string().describe("The regex pattern to search for in file contents"),
+    include: tool.schema
+      .string()
+      .optional()
+      .describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
+    path: tool.schema
+      .string()
+      .optional()
+      .describe("The directory to search in. Defaults to the current working directory."),
+  },
+  execute: async (args) => {
+    try {
+      const globs = args.include ? [args.include] : undefined
+      const paths = args.path ? [args.path] : undefined
+
+      const result = await runRg({
+        pattern: args.pattern,
+        paths,
+        globs,
+        context: 0,
+      })
+
+      return formatGrepResult(result)
+    } catch (e) {
+      return `Error: ${e instanceof Error ? e.message : String(e)}`
+    }
+  },
+})

+ 38 - 0
src/tools/grep/types.ts

@@ -0,0 +1,38 @@
+export interface GrepMatch {
+  file: string
+  line: number
+  text: string
+}
+
+export interface GrepResult {
+  matches: GrepMatch[]
+  totalMatches: number
+  filesSearched: number
+  truncated: boolean
+  error?: string
+}
+
+export interface CountResult {
+  file: string
+  count: number
+}
+
+export interface GrepOptions {
+  pattern: string
+  paths?: string[]
+  globs?: string[]
+  excludeGlobs?: string[]
+  context?: number
+  caseSensitive?: boolean
+  wholeWord?: boolean
+  fixedStrings?: boolean
+  multiline?: boolean
+  hidden?: boolean
+  noIgnore?: boolean
+  fileType?: string[]
+  maxDepth?: number
+  maxFilesize?: string
+  maxCount?: number
+  maxColumns?: number
+  timeout?: number
+}

+ 37 - 0
src/tools/grep/utils.ts

@@ -0,0 +1,37 @@
+import type { GrepResult } from "./types"
+
+export function formatGrepResult(result: GrepResult): string {
+  if (result.error) {
+    return `Error: ${result.error}`
+  }
+
+  if (result.matches.length === 0) {
+    return "No matches found."
+  }
+
+  const lines: string[] = []
+
+  // Group matches by file
+  const byFile = new Map<string, { line: number; text: string }[]>()
+  for (const match of result.matches) {
+    const existing = byFile.get(match.file) || []
+    existing.push({ line: match.line, text: match.text })
+    byFile.set(match.file, existing)
+  }
+
+  for (const [file, matches] of byFile) {
+    lines.push(`\n${file}:`)
+    for (const match of matches) {
+      lines.push(`  ${match.line}: ${match.text}`)
+    }
+  }
+
+  const summary = `Found ${result.totalMatches} matches in ${result.filesSearched} files`
+  if (result.truncated) {
+    lines.push(`\n${summary} (output truncated)`)
+  } else {
+    lines.push(`\n${summary}`)
+  }
+
+  return lines.join("\n")
+}

+ 6 - 0
src/tools/index.ts

@@ -6,3 +6,9 @@ export {
   lsp_rename,
   lspManager,
 } from "./lsp";
+
+// Grep tool (ripgrep-based)
+export { grep } from "./grep";
+
+// AST-grep tools
+export { ast_grep_search, ast_grep_replace } from "./ast-grep";