Skip to content

feat(init): add grep and glob local-op handlers#703

Merged
betegon merged 1 commit intomainfrom
feat/grep-glob-tools
Apr 10, 2026
Merged

feat(init): add grep and glob local-op handlers#703
betegon merged 1 commit intomainfrom
feat/grep-glob-tools

Conversation

@betegon
Copy link
Copy Markdown
Member

@betegon betegon commented Apr 9, 2026

Summary

Adds two new local-op types so the server can search project files without reading them all — eliminates the need for "FILE SELECTION mode" LLM calls in many cases.

  • grep: regex search across files with optional glob filter. Batched — multiple patterns in one round-trip. Capped at 100 matches per search, 2000-char line truncation.
  • glob: find files by name pattern. Batched — multiple patterns in one round-trip. Capped at 100 results.

Both use a Node.js fs.readdir/readFile implementation (no ripgrep dependency required). Skips node_modules, .git, __pycache__, .venv, dist, build by default.

Server-side counterpart (schemas + step prompt updates) will come in a separate cli-init-api PR.

Test plan

  • 8 new tests: grep (single pattern, batch, include filter, no match, path sandbox) + glob (single, batch, no match)
  • All 171 init tests pass
  • Lint clean

Made with Cursor

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Docs

  • Deploy main branch preview alongside PR previews by BYK in #707
  • Enable sourcemap upload, releases, and environment tracking by BYK in #705

Init

  • Add grep and glob local-op handlers by betegon in #703
  • Add fuzzy edit replacers and edits-based apply-patchset by betegon in #698

Other

  • (cli) Hoist global flags from any argv position and add -v alias by BYK in #709
  • (commands) Add buildRouteMap wrapper with standard subcommand aliases by BYK in #690
  • (config) Support .sentryclirc config file for per-directory defaults by BYK in #693
  • (install) Add SENTRY_INIT env var to run wizard after install by betegon in #685
  • (release) Surface adoption and health metrics in list and view (Add release command group with adoption/health subcommand #463) by BYK in #680
  • (telemetry) Add agent detection tag for AI coding tools by betegon in #687

Bug Fixes 🐛

Dashboard

  • Add --layout flag to widget add for predictable placement by BYK in #700
  • Render tracemetrics widgets in dashboard view by BYK in #695

Other

  • (build) Enable sourcemap resolution for compiled binaries by BYK in #701
  • (cache) --fresh flag now updates cache with fresh response by BYK in #708
  • (eval) Ground LLM judge with command reference to prevent false negatives by BYK in #712
  • (init) Narrow command validation to actual shell injection vectors by betegon in #697
  • (init,feedback) Default to tracing only in feature select and attach user email to feedback by MathurAditya724 in #688
  • (setup) Handle read-only .claude directory in sandboxed environments by BYK in #702
  • Inject auth token into generated .env.sentry-build-plugin files by MathurAditya724 in #706

Internal Changes 🔧

  • (docs) Gitignore generated command docs, extract fragments by BYK in #696
  • (eval) Replace OpenAI with Anthropic SDK in init-eval judge by betegon in #683
  • (init) Use markdown pipeline for spinner messages by betegon in #686
  • Regenerate skill files and command docs by github-actions[bot] in 584ec0e0

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-703/

Built to branch gh-pages at 2026-04-10 12:07 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Codecov Results 📊

134 passed | Total: 134 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

✅ Patch coverage is 100.00%. Project has 1574 uncovered lines.
✅ Project coverage is 95.35%. Comparing base (base) to head (head).

Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    95.33%    95.35%    +0.02%
==========================================
  Files          232       232         —
  Lines        33632     33828      +196
  Branches         0         0         —
==========================================
+ Hits         32059     32254      +195
- Misses        1573      1574        +1
- Partials         0         0         —

Generated by Codecov Action

@betegon betegon marked this pull request as ready for review April 9, 2026 14:14
@betegon betegon force-pushed the feat/grep-glob-tools branch from 1de46f8 to 9eb23a0 Compare April 9, 2026 15:41
@betegon betegon force-pushed the feat/grep-glob-tools branch from 9eb23a0 to d9c6ce3 Compare April 9, 2026 16:01
@betegon betegon force-pushed the feat/grep-glob-tools branch from d9c6ce3 to 031cdee Compare April 9, 2026 16:22
@betegon betegon force-pushed the feat/grep-glob-tools branch from 031cdee to b2f5fe8 Compare April 9, 2026 17:04
@betegon betegon force-pushed the feat/grep-glob-tools branch 3 times, most recently from 12c73de to ecd6915 Compare April 10, 2026 08:55
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Truthiness check drops matches with empty line text
    • Changed the guard condition from truthiness check to null check (m[3] != null) to correctly handle empty string matches from git grep.
  • ✅ Fixed: Spawn timeout silently returns empty results instead of error
    • Modified spawnCollect to detect SIGTERM signal and reject with timeout error instead of resolving with exit code 1, enabling proper fallback chain.

Create PR

Or push these changes by commenting:

@cursor push 1b6cd595f2
Preview (1b6cd595f2)
diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts
--- a/src/lib/init/local-ops.ts
+++ b/src/lib/init/local-ops.ts
@@ -34,6 +34,8 @@
   DetectSentryPayload,
   DirEntry,
   FileExistsBatchPayload,
+  GlobPayload,
+  GrepPayload,
   ListDirPayload,
   LocalOpPayload,
   LocalOpResult,
@@ -296,6 +298,10 @@
         return await runCommands(payload, options.dryRun);
       case "apply-patchset":
         return await applyPatchset(payload, options.dryRun);
+      case "grep":
+        return await grep(payload);
+      case "glob":
+        return await glob(payload);
       case "create-sentry-project":
         return await createSentryProject(payload, options);
       case "detect-sentry":
@@ -849,6 +855,466 @@
   };
 }
 
+// ── Grep & Glob ─────────────────────────────────────────────────────
+
+const MAX_GREP_RESULTS_PER_SEARCH = 100;
+const MAX_GREP_LINE_LENGTH = 2000;
+const MAX_GLOB_RESULTS = 100;
+const SKIP_DIRS = new Set([
+  "node_modules",
+  ".git",
+  "__pycache__",
+  ".venv",
+  "venv",
+  "dist",
+  "build",
+]);
+
+type GrepMatch = { path: string; lineNum: number; line: string };
+
+// ── Ripgrep implementations (preferred when rg is on PATH) ──────────
+
+/**
+ * Spawn a command, collect stdout + stderr, reject on spawn errors (ENOENT).
+ * Drains both streams to prevent pipe buffer deadlocks.
+ */
+function spawnCollect(
+  cmd: string,
+  args: string[],
+  cwd: string
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(cmd, args, {
+      cwd,
+      stdio: ["ignore", "pipe", "pipe"],
+      timeout: 30_000,
+    });
+
+    const outChunks: Buffer[] = [];
+    let outLen = 0;
+    child.stdout.on("data", (chunk: Buffer) => {
+      if (outLen < MAX_OUTPUT_BYTES) {
+        outChunks.push(chunk);
+        outLen += chunk.length;
+      }
+    });
+
+    const errChunks: Buffer[] = [];
+    child.stderr.on("data", (chunk: Buffer) => {
+      if (errChunks.length < 64) {
+        errChunks.push(chunk);
+      }
+    });
+
+    child.on("error", (err) => {
+      reject(err);
+    });
+    child.on("close", (code, signal) => {
+      if (code === null && signal === "SIGTERM") {
+        reject(new Error("Command timed out after 30 seconds"));
+        return;
+      }
+      resolve({
+        stdout: Buffer.concat(outChunks).toString("utf-8"),
+        stderr: Buffer.concat(errChunks).toString("utf-8"),
+        exitCode: code ?? 1,
+      });
+    });
+  });
+}
+
+/**
+ * Parse ripgrep output using `|` as field separator (set via
+ * `--field-match-separator=|`) to avoid ambiguity with `:` in
+ * Windows drive-letter paths.
+ * Format: filepath|linenum|matched text
+ */
+function parseRgGrepOutput(
+  cwd: string,
+  stdout: string,
+  maxResults: number
+): { matches: GrepMatch[]; truncated: boolean } {
+  const lines = stdout.split("\n").filter(Boolean);
+  const truncated = lines.length > maxResults;
+  const matches: GrepMatch[] = [];
+
+  for (const line of lines.slice(0, maxResults)) {
+    const firstSep = line.indexOf("|");
+    if (firstSep === -1) {
+      continue;
+    }
+    const filePart = line.substring(0, firstSep);
+    const rest = line.substring(firstSep + 1);
+    const secondSep = rest.indexOf("|");
+    if (secondSep === -1) {
+      continue;
+    }
+    const lineNum = Number.parseInt(rest.substring(0, secondSep), 10);
+    let text = rest.substring(secondSep + 1);
+    if (text.length > MAX_GREP_LINE_LENGTH) {
+      text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+    }
+    matches.push({ path: path.relative(cwd, filePart), lineNum, line: text });
+  }
+
+  return { matches, truncated };
+}
+
+async function rgGrepSearch(opts: {
+  cwd: string;
+  pattern: string;
+  target: string;
+  include: string | undefined;
+  maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+  const { cwd, pattern, target, include, maxResults } = opts;
+  const args = [
+    "-nH",
+    "--no-messages",
+    "--hidden",
+    "--field-match-separator=|",
+    "--regexp",
+    pattern,
+  ];
+  if (include) {
+    args.push("--glob", include);
+  }
+  args.push(target);
+
+  const { stdout, exitCode } = await spawnCollect("rg", args, cwd);
+
+  if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) {
+    return { matches: [], truncated: false };
+  }
+  if (exitCode !== 0 && exitCode !== 2) {
+    throw new Error(`ripgrep failed with exit code ${exitCode}`);
+  }
+
+  return parseRgGrepOutput(cwd, stdout, maxResults);
+}
+
+async function rgGlobSearch(opts: {
+  cwd: string;
+  pattern: string;
+  target: string;
+  maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+  const { cwd, pattern, target, maxResults } = opts;
+  const args = ["--files", "--hidden", "--glob", pattern, target];
+
+  const { stdout, exitCode } = await spawnCollect("rg", args, cwd);
+
+  if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) {
+    return { files: [], truncated: false };
+  }
+  if (exitCode !== 0 && exitCode !== 2) {
+    throw new Error(`ripgrep failed with exit code ${exitCode}`);
+  }
+
+  const lines = stdout.split("\n").filter(Boolean);
+  const truncated = lines.length > maxResults;
+  const files = lines.slice(0, maxResults).map((f) => path.relative(cwd, f));
+  return { files, truncated };
+}
+
+// ── Node.js fallback (when rg is not installed) ─────────────────────
+
+/**
+ * Recursively walk a directory, yielding relative file paths.
+ * Skips common non-source directories and respects an optional glob filter.
+ */
+async function* walkFiles(
+  root: string,
+  base: string,
+  globPattern: string | undefined
+): AsyncGenerator<string> {
+  let entries: fs.Dirent[];
+  try {
+    entries = await fs.promises.readdir(base, { withFileTypes: true });
+  } catch {
+    return;
+  }
+  for (const entry of entries) {
+    const full = path.join(base, entry.name);
+    const rel = path.relative(root, full);
+    if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
+      yield* walkFiles(root, full, globPattern);
+    } else if (entry.isFile()) {
+      const matchTarget = globPattern?.includes("/") ? rel : entry.name;
+      if (!globPattern || matchGlob(matchTarget, globPattern)) {
+        yield rel;
+      }
+    }
+  }
+}
+
+/** Minimal glob matcher — supports `*`, `**`, and `?` wildcards. */
+function matchGlob(name: string, pattern: string): boolean {
+  const re = pattern
+    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+    .replace(/\*\*/g, "\0")
+    .replace(/\*/g, "[^/]*")
+    .replace(/\0/g, ".*")
+    .replace(/\?/g, ".");
+  return new RegExp(`^${re}$`).test(name);
+}
+
+/**
+ * Search files for a regex pattern using Node.js fs. Fallback for when
+ * ripgrep is not available.
+ */
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: file-walking search with early exits
+async function fsGrepSearch(opts: {
+  cwd: string;
+  pattern: string;
+  searchPath: string | undefined;
+  include: string | undefined;
+  maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+  const { cwd, pattern, searchPath, include, maxResults } = opts;
+  const target = searchPath ? safePath(cwd, searchPath) : cwd;
+  const regex = new RegExp(pattern);
+  const matches: GrepMatch[] = [];
+
+  for await (const rel of walkFiles(cwd, target, include)) {
+    if (matches.length > maxResults) {
+      break;
+    }
+    const absPath = path.join(cwd, rel);
+    let content: string;
+    try {
+      const stat = await fs.promises.stat(absPath);
+      if (stat.size > MAX_FILE_BYTES) {
+        continue;
+      }
+      content = await fs.promises.readFile(absPath, "utf-8");
+    } catch {
+      continue;
+    }
+    const lines = content.split("\n");
+    for (let i = 0; i < lines.length; i += 1) {
+      const line = lines[i] ?? "";
+      if (regex.test(line)) {
+        let text = line;
+        if (text.length > MAX_GREP_LINE_LENGTH) {
+          text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+        }
+        matches.push({ path: rel, lineNum: i + 1, line: text });
+        if (matches.length > maxResults) {
+          break;
+        }
+      }
+    }
+  }
+
+  const truncated = matches.length > maxResults;
+  if (truncated) {
+    matches.length = maxResults;
+  }
+  return { matches, truncated };
+}
+
+async function fsGlobSearch(opts: {
+  cwd: string;
+  pattern: string;
+  searchPath: string | undefined;
+  maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+  const { cwd, pattern, searchPath, maxResults } = opts;
+  const target = searchPath ? safePath(cwd, searchPath) : cwd;
+  const files: string[] = [];
+
+  for await (const rel of walkFiles(cwd, target, pattern)) {
+    files.push(rel);
+    if (files.length > maxResults) {
+      break;
+    }
+  }
+
+  const truncated = files.length > maxResults;
+  if (truncated) {
+    files.length = maxResults;
+  }
+  return { files, truncated };
+}
+
+// ── git grep / git ls-files (middle fallback tier) ──────────────────
+
+const GREP_LINE_RE = /^(.+?):(\d+):(.*)$/;
+
+function parseGrepOutput(
+  stdout: string,
+  maxResults: number,
+  pathPrefix?: string
+): { matches: GrepMatch[]; truncated: boolean } {
+  const lines = stdout.split("\n").filter(Boolean);
+  const matches: GrepMatch[] = [];
+
+  for (const line of lines) {
+    const m = line.match(GREP_LINE_RE);
+    if (!(m?.[1] && m[2] && m[3] != null)) {
+      continue;
+    }
+    const lineNum = Number.parseInt(m[2], 10);
+    let text: string = m[3];
+    if (text.length > MAX_GREP_LINE_LENGTH) {
+      text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+    }
+    const filePath = pathPrefix ? path.join(pathPrefix, m[1]) : m[1];
+    matches.push({ path: filePath, lineNum, line: text });
+    if (matches.length > maxResults) {
+      break;
+    }
+  }
+
+  const truncated = matches.length > maxResults;
+  if (truncated) {
+    matches.length = maxResults;
+  }
+  return { matches, truncated };
+}
+
+async function gitGrepSearch(opts: {
+  cwd: string;
+  pattern: string;
+  target: string;
+  include: string | undefined;
+  maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+  const { cwd, pattern, target, include, maxResults } = opts;
+  const args = ["grep", "--untracked", "-n", "-E", pattern];
+  if (include) {
+    args.push("--", include);
+  }
+
+  const { stdout, exitCode } = await spawnCollect("git", args, target);
+
+  if (exitCode === 1) {
+    return { matches: [], truncated: false };
+  }
+  if (exitCode !== 0) {
+    throw new Error(`git grep failed with exit code ${exitCode}`);
+  }
+
+  const prefix = path.relative(cwd, target);
+  return parseGrepOutput(stdout, maxResults, prefix || undefined);
+}
+
+async function gitLsFiles(opts: {
+  cwd: string;
+  pattern: string;
+  target: string;
+  maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+  const { cwd, pattern, target, maxResults } = opts;
+  const args = [
+    "ls-files",
+    "--cached",
+    "--others",
+    "--exclude-standard",
+    pattern,
+  ];
+
+  const { stdout, exitCode } = await spawnCollect("git", args, target);
+
+  if (exitCode !== 0) {
+    throw new Error(`git ls-files failed with exit code ${exitCode}`);
+  }
+
+  const lines = stdout.split("\n").filter(Boolean);
+  const truncated = lines.length > maxResults;
+  const files = lines
+    .slice(0, maxResults)
+    .map((f) => path.relative(cwd, path.resolve(target, f)));
+  return { files, truncated };
+}
+
+// ── Dispatch: rg → git → Node.js ────────────────────────────────────
+
+async function grepSearch(opts: {
+  cwd: string;
+  pattern: string;
+  searchPath: string | undefined;
+  include: string | undefined;
+  maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+  const target = opts.searchPath
+    ? safePath(opts.cwd, opts.searchPath)
+    : opts.cwd;
+  const resolvedOpts = { ...opts, target };
+  try {
+    return await rgGrepSearch(resolvedOpts);
+  } catch {
+    try {
+      return await gitGrepSearch(resolvedOpts);
+    } catch {
+      return await fsGrepSearch(opts);
+    }
+  }
+}
+
+async function globSearchImpl(opts: {
+  cwd: string;
+  pattern: string;
+  searchPath: string | undefined;
+  maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+  const target = opts.searchPath
+    ? safePath(opts.cwd, opts.searchPath)
+    : opts.cwd;
+  const resolvedOpts = { ...opts, target };
+  try {
+    return await rgGlobSearch(resolvedOpts);
+  } catch {
+    try {
+      return await gitLsFiles(resolvedOpts);
+    } catch {
+      return await fsGlobSearch(opts);
+    }
+  }
+}
+
+async function grep(payload: GrepPayload): Promise<LocalOpResult> {
+  const { cwd, params } = payload;
+  const maxResults = params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH;
+
+  const results = await Promise.all(
+    params.searches.map(async (search) => {
+      const { matches, truncated } = await grepSearch({
+        cwd,
+        pattern: search.pattern,
+        searchPath: search.path,
+        include: search.include,
+        maxResults,
+      });
+      return { pattern: search.pattern, matches, truncated };
+    })
+  );
+
+  return { ok: true, data: { results } };
+}
+
+async function glob(payload: GlobPayload): Promise<LocalOpResult> {
+  const { cwd, params } = payload;
+  const maxResults = params.maxResults ?? MAX_GLOB_RESULTS;
+
+  const results = await Promise.all(
+    params.patterns.map(async (pattern) => {
+      const { files, truncated } = await globSearchImpl({
+        cwd,
+        pattern,
+        searchPath: params.path,
+        maxResults,
+      });
+      return { pattern, files, truncated };
+    })
+  );
+
+  return { ok: true, data: { results } };
+}
+
+// ── Sentry project + DSN ────────────────────────────────────────────
+
 async function createSentryProject(
   payload: CreateSentryProjectPayload,
   options: WizardOptions

diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts
--- a/src/lib/init/types.ts
+++ b/src/lib/init/types.ts
@@ -25,6 +25,8 @@
   | FileExistsBatchPayload
   | RunCommandsPayload
   | ApplyPatchsetPayload
+  | GrepPayload
+  | GlobPayload
   | CreateSentryProjectPayload
   | DetectSentryPayload;
 
@@ -69,6 +71,33 @@
   };
 };
 
+export type GrepSearch = {
+  pattern: string;
+  path?: string;
+  include?: string;
+};
+
+export type GrepPayload = {
+  type: "local-op";
+  operation: "grep";
+  cwd: string;
+  params: {
+    searches: GrepSearch[];
+    maxResultsPerSearch?: number;
+  };
+};
+
+export type GlobPayload = {
+  type: "local-op";
+  operation: "glob";
+  cwd: string;
+  params: {
+    patterns: string[];
+    path?: string;
+    maxResults?: number;
+  };
+};
+
 export type PatchEdit = {
   oldString: string;
   newString: string;

diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts
--- a/src/lib/init/wizard-runner.ts
+++ b/src/lib/init/wizard-runner.ts
@@ -137,6 +137,20 @@
     }
     case "list-dir":
       return "Listing directory...";
+    case "grep": {
+      const searches = payload.params.searches;
+      if (searches.length === 1 && searches[0]) {
+        return `Searching for ${safeCodeSpan(searches[0].pattern)}...`;
+      }
+      return `Running ${searches.length} searches...`;
+    }
+    case "glob": {
+      const patterns = payload.params.patterns;
+      if (patterns.length === 1 && patterns[0]) {
+        return `Finding files matching ${safeCodeSpan(patterns[0])}...`;
+      }
+      return `Finding files (${patterns.length} patterns)...`;
+    }
     case "create-sentry-project":
       return `Creating project ${safeCodeSpan(payload.params.name)} (${payload.params.platform})...`;
     case "detect-sentry":

diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts
--- a/test/lib/init/local-ops.test.ts
+++ b/test/lib/init/local-ops.test.ts
@@ -16,6 +16,8 @@
 import type {
   ApplyPatchsetPayload,
   FileExistsBatchPayload,
+  GlobPayload,
+  GrepPayload,
   ListDirPayload,
   LocalOpPayload,
   ReadFilesPayload,
@@ -1120,3 +1122,233 @@
     expect(paths).toContain(join("a", "nested.ts"));
   });
 });
+
+describe("grep", () => {
+  let testDir: string;
+  let options: WizardOptions;
+
+  beforeEach(() => {
+    testDir = mkdtempSync(join("/tmp", "grep-test-"));
+    options = makeOptions({ directory: testDir });
+    // Init a git repo so git grep / git ls-files tier is exercised
+    const { execSync } = require("node:child_process");
+    execSync("git init -q", { cwd: testDir });
+    writeFileSync(
+      join(testDir, "app.ts"),
+      'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n'
+    );
+    writeFileSync(
+      join(testDir, "utils.ts"),
+      "export function helper() { return 1; }\n"
+    );
+    mkdirSync(join(testDir, "src"));
+    writeFileSync(
+      join(testDir, "src", "index.ts"),
+      'import { helper } from "./utils";\nSentry.init({});\n'
+    );
+  });
+
+  afterEach(() => {
+    rmSync(testDir, { recursive: true, force: true });
+  });
+
+  test("finds matches for a single pattern", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "Sentry\\.init" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{
+        pattern: string;
+        matches: Array<{ path: string; lineNum: number; line: string }>;
+        truncated: boolean;
+      }>;
+    };
+    expect(data.results).toHaveLength(1);
+    expect(data.results[0].matches.length).toBeGreaterThanOrEqual(2);
+    expect(data.results[0].truncated).toBe(false);
+  });
+
+  test("supports multiple search patterns in one call", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "@sentry/node" }, { pattern: "helper" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{ pattern: string; matches: unknown[] }>;
+    };
+    expect(data.results).toHaveLength(2);
+    expect(data.results[0].pattern).toBe("@sentry/node");
+    expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1);
+    expect(data.results[1].pattern).toBe("helper");
+    expect(data.results[1].matches.length).toBeGreaterThanOrEqual(1);
+  });
+
+  test("supports include glob filter", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "Sentry", include: "app.*" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{ matches: Array<{ path: string }> }>;
+    };
+    for (const match of data.results[0].matches) {
+      expect(match.path).toContain("app");
+    }
+  });
+
+  test("returns empty matches for non-matching pattern", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "nonexistent_string_xyz" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as { results: Array<{ matches: unknown[] }> };
+    expect(data.results[0].matches).toHaveLength(0);
+  });
+
+  test("returns paths relative to cwd when searching a subdirectory", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "helper", path: "src" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{
+        matches: Array<{ path: string; lineNum: number }>;
+      }>;
+    };
+    expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1);
+    for (const match of data.results[0].matches) {
+      expect(match.path).toMatch(/^src\//);
+    }
+  });
+
+  test("respects path sandbox", async () => {
+    const payload: GrepPayload = {
+      type: "local-op",
+      operation: "grep",
+      cwd: testDir,
+      params: {
+        searches: [{ pattern: "test", path: "../../etc" }],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(false);
+    expect(result.error).toContain("outside project directory");
+  });
+});
+
+describe("glob", () => {
+  let testDir: string;
+  let options: WizardOptions;
+
+  beforeEach(() => {
+    testDir = mkdtempSync(join("/tmp", "glob-test-"));
+    options = makeOptions({ directory: testDir });
+    const { execSync } = require("node:child_process");
+    execSync("git init -q", { cwd: testDir });
+    writeFileSync(join(testDir, "app.ts"), "x");
+    writeFileSync(join(testDir, "utils.ts"), "x");
+    writeFileSync(join(testDir, "config.json"), "{}");
+    mkdirSync(join(testDir, "src"));
+    writeFileSync(join(testDir, "src", "index.ts"), "x");
+  });
+
+  afterEach(() => {
+    rmSync(testDir, { recursive: true, force: true });
+  });
+
+  test("finds files matching a single pattern", async () => {
+    const payload: GlobPayload = {
+      type: "local-op",
+      operation: "glob",
+      cwd: testDir,
+      params: {
+        patterns: ["*.ts"],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{ pattern: string; files: string[]; truncated: boolean }>;
+    };
+    expect(data.results).toHaveLength(1);
+    expect(data.results[0].files.length).toBeGreaterThanOrEqual(2);
+    expect(data.results[0].truncated).toBe(false);
+    for (const f of data.results[0].files) {
+      expect(f).toMatch(/\.ts$/);
+    }
+  });
+
+  test("supports multiple patterns in one call", async () => {
+    const payload: GlobPayload = {
+      type: "local-op",
+      operation: "glob",
+      cwd: testDir,
+      params: {
+        patterns: ["*.ts", "*.json"],
+      },
+    };
+
+    const result = await handleLocalOp(payload, options);
+    expect(result.ok).toBe(true);
+    const data = result.data as {
+      results: Array<{ pattern: string; files: string[] }>;
+    };
+    expect(data.results).toHaveLength(2);
+    expect(data.results[0].files.length).toBeGreaterThanOrEqual(2);
+    expect(data.results[1].files.length).toBeGreaterThanOrEqual(1);
+  });
+
+  test("returns empty for non-matching pattern", async () => {
+    const payload: GlobPayload = {
+      type: "local-op",
+      operation: "glob",
+      cwd: testDir,
+      params: {
+        patterns: ["*.xyz"],
... diff truncated: showing 800 of 809 lines

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@betegon betegon force-pushed the feat/grep-glob-tools branch from ecd6915 to 601bea2 Compare April 10, 2026 09:05
@betegon betegon force-pushed the feat/grep-glob-tools branch 3 times, most recently from 56b2e72 to 7015444 Compare April 10, 2026 09:33
Add two new local-op types that let the server search project files
without reading them all:

- grep: regex search across files with optional glob filter, batched
  (multiple patterns in one round-trip), capped at 100 matches per
  search with 2000-char line truncation

- glob: find files by pattern, batched (multiple patterns in one
  round-trip), capped at 100 results

Three-tier fallback chain (following gemini-cli's approach):
1. ripgrep (rg) — fastest, binary-safe, .gitignore-aware
2. git grep / git ls-files — fast, available on nearly every dev machine
3. Node.js fs walk — always works, no dependencies

Counterpart server-side schemas in cli-init-api PR #80.

Made-with: Cursor
@betegon betegon force-pushed the feat/grep-glob-tools branch from 7015444 to dfd6f1c Compare April 10, 2026 12:07
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dfd6f1c. Configure here.

@betegon betegon merged commit db8f92e into main Apr 10, 2026
26 checks passed
@betegon betegon deleted the feat/grep-glob-tools branch April 10, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant