grep.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import z from "zod/v4"
  2. import { Tool } from "./tool"
  3. import { Ripgrep } from "../file/ripgrep"
  4. import DESCRIPTION from "./grep.txt"
  5. import { Instance } from "../project/instance"
  6. export const GrepTool = Tool.define("grep", {
  7. description: DESCRIPTION,
  8. parameters: z.object({
  9. pattern: z.string().describe("The regex pattern to search for in file contents"),
  10. path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
  11. include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
  12. }),
  13. async execute(params) {
  14. if (!params.pattern) {
  15. throw new Error("pattern is required")
  16. }
  17. const searchPath = params.path || Instance.directory
  18. const rgPath = await Ripgrep.filepath()
  19. const args = ["-n", params.pattern]
  20. if (params.include) {
  21. args.push("--glob", params.include)
  22. }
  23. args.push(searchPath)
  24. const proc = Bun.spawn([rgPath, ...args], {
  25. stdout: "pipe",
  26. stderr: "pipe",
  27. })
  28. const output = await new Response(proc.stdout).text()
  29. const errorOutput = await new Response(proc.stderr).text()
  30. const exitCode = await proc.exited
  31. if (exitCode === 1) {
  32. return {
  33. title: params.pattern,
  34. metadata: { matches: 0, truncated: false },
  35. output: "No files found",
  36. }
  37. }
  38. if (exitCode !== 0) {
  39. throw new Error(`ripgrep failed: ${errorOutput}`)
  40. }
  41. const lines = output.trim().split("\n")
  42. const matches = []
  43. for (const line of lines) {
  44. if (!line) continue
  45. const [filePath, lineNumStr, ...lineTextParts] = line.split(":")
  46. if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
  47. const lineNum = parseInt(lineNumStr, 10)
  48. const lineText = lineTextParts.join(":")
  49. const file = Bun.file(filePath)
  50. const stats = await file.stat().catch(() => null)
  51. if (!stats) continue
  52. matches.push({
  53. path: filePath,
  54. modTime: stats.mtime.getTime(),
  55. lineNum,
  56. lineText,
  57. })
  58. }
  59. matches.sort((a, b) => b.modTime - a.modTime)
  60. const limit = 100
  61. const truncated = matches.length > limit
  62. const finalMatches = truncated ? matches.slice(0, limit) : matches
  63. if (finalMatches.length === 0) {
  64. return {
  65. title: params.pattern,
  66. metadata: { matches: 0, truncated: false },
  67. output: "No files found",
  68. }
  69. }
  70. const outputLines = [`Found ${finalMatches.length} matches`]
  71. let currentFile = ""
  72. for (const match of finalMatches) {
  73. if (currentFile !== match.path) {
  74. if (currentFile !== "") {
  75. outputLines.push("")
  76. }
  77. currentFile = match.path
  78. outputLines.push(`${match.path}:`)
  79. }
  80. outputLines.push(` Line ${match.lineNum}: ${match.lineText}`)
  81. }
  82. if (truncated) {
  83. outputLines.push("")
  84. outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
  85. }
  86. return {
  87. title: params.pattern,
  88. metadata: {
  89. matches: finalMatches.length,
  90. truncated,
  91. },
  92. output: outputLines.join("\n"),
  93. }
  94. },
  95. })