euron-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
euron_code/tools/fs.py ADDED
@@ -0,0 +1,412 @@
1
+ """File tools: read_file, write_file, edit_file, multi_edit, list_dir."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from euron_code.permissions import Request
11
+ from euron_code.tools.base import Tool, ToolContext, ToolError, ToolResult, opt_int, require, truncate
12
+ from euron_code.tools.ignore import ALWAYS_SKIP_DIRS, GitIgnore, is_binary_bytes
13
+
14
+ DEFAULT_READ_LIMIT = 400
15
+ MAX_LINE_CHARS = 1000
16
+ MAX_FILE_BYTES = 5 * 1024 * 1024
17
+
18
+
19
+ # ---------------------------------------------------------------- text helpers
20
+
21
+ class TextFile:
22
+ """A decoded text file that remembers its BOM so writes round-trip."""
23
+
24
+ def __init__(self, text: str, bom: bool):
25
+ self.text = text
26
+ self.bom = bom
27
+
28
+ @property
29
+ def crlf(self) -> bool:
30
+ return "\r\n" in self.text
31
+
32
+
33
+ def load_text(path: Path) -> TextFile:
34
+ if not path.exists():
35
+ raise ToolError(f"File not found: {path}")
36
+ if path.is_dir():
37
+ raise ToolError(f"{path} is a directory. Use list_dir.")
38
+ size = path.stat().st_size
39
+ if size > MAX_FILE_BYTES:
40
+ raise ToolError(f"{path} is {size // 1024} KB, too large to load. Use grep or read a line range.")
41
+ data = path.read_bytes()
42
+ if is_binary_bytes(data):
43
+ raise ToolError(f"{path} looks like a binary file; not reading it.")
44
+ bom = data.startswith(b"\xef\xbb\xbf")
45
+ if bom:
46
+ data = data[3:]
47
+ try:
48
+ text = data.decode("utf-8")
49
+ except UnicodeDecodeError:
50
+ raise ToolError(f"{path} is not UTF-8 text.")
51
+ return TextFile(text, bom)
52
+
53
+
54
+ def save_text(path: Path, tf: TextFile) -> None:
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ data = tf.text.encode("utf-8")
57
+ if tf.bom:
58
+ data = b"\xef\xbb\xbf" + data
59
+ tmp = path.with_name(path.name + ".euron-tmp")
60
+ with open(tmp, "wb") as f:
61
+ f.write(data)
62
+ try:
63
+ if path.exists():
64
+ os.chmod(tmp, path.stat().st_mode)
65
+ except OSError:
66
+ pass
67
+ os.replace(tmp, path)
68
+
69
+
70
+ def make_diff(old: str, new: str, label: str) -> str:
71
+ a = old.replace("\r\n", "\n").splitlines(keepends=True)
72
+ b = new.replace("\r\n", "\n").splitlines(keepends=True)
73
+ lines = difflib.unified_diff(a, b, fromfile=f"a/{label}", tofile=f"b/{label}", n=3)
74
+ out = []
75
+ for ln in lines:
76
+ out.append(ln if ln.endswith("\n") else ln + "\n\\n")
77
+ return "".join(out)
78
+
79
+
80
+ def _adapt_newlines(s: str, crlf: bool) -> str:
81
+ if crlf and "\r\n" not in s:
82
+ return s.replace("\n", "\r\n")
83
+ return s
84
+
85
+
86
+ def _near_miss_hint(text: str, old: str) -> str:
87
+ first = next((ln for ln in old.splitlines() if ln.strip()), "").strip()
88
+ if not first:
89
+ return ""
90
+ lines = text.splitlines()
91
+ stripped = [ln.strip() for ln in lines]
92
+ if first in stripped:
93
+ n = stripped.index(first) + 1
94
+ return (f" A line matching the first line of old_string exists at line {n}, so the "
95
+ "difference is probably indentation or whitespace. Re-read that range and copy it exactly.")
96
+ close = difflib.get_close_matches(first, stripped, n=1, cutoff=0.7)
97
+ if close:
98
+ n = stripped.index(close[0]) + 1
99
+ return f" The closest line is {n}: {lines[n - 1].strip()[:120]!r}. Re-read the file before retrying."
100
+ return " Re-read the file; it may have changed."
101
+
102
+
103
+ def apply_edit(text: str, old: str, new: str, replace_all: bool = False) -> Tuple[str, int]:
104
+ """Exact string replacement with a unique-match check. Returns (new_text, count)."""
105
+ if old == "":
106
+ raise ToolError("old_string is empty. To create or overwrite a file use write_file.")
107
+ if old == new:
108
+ raise ToolError("old_string and new_string are identical; nothing to change.")
109
+ crlf = "\r\n" in text
110
+ old_a, new_a = _adapt_newlines(old, crlf), _adapt_newlines(new, crlf)
111
+ count = text.count(old_a)
112
+ if count == 0 and old_a != old:
113
+ old_a, new_a = old, new
114
+ count = text.count(old_a)
115
+ if count == 0:
116
+ raise ToolError("old_string was not found in the file." + _near_miss_hint(text, old))
117
+ if count > 1 and not replace_all:
118
+ raise ToolError(
119
+ f"old_string matches {count} places. Add more surrounding lines to make it unique, "
120
+ "or set replace_all to true to change every occurrence."
121
+ )
122
+ return text.replace(old_a, new_a), count
123
+
124
+
125
+ # ---------------------------------------------------------------- read_file
126
+
127
+ class ReadFile(Tool):
128
+ name = "read_file"
129
+ read_only = True
130
+ description = (
131
+ "Read a text file from the project. Output has line numbers (` 12\\tcode`); the numbers "
132
+ "are not part of the file. Reads up to 400 lines by default; use offset/limit for long files. "
133
+ "Always read a file before editing it."
134
+ )
135
+ parameters = {
136
+ "type": "object",
137
+ "properties": {
138
+ "path": {"type": "string", "description": "File path, relative to the project root or absolute."},
139
+ "offset": {"type": "integer", "description": "1-based line to start from (default 1)."},
140
+ "limit": {"type": "integer", "description": "Maximum number of lines (default 400)."},
141
+ },
142
+ "required": ["path"],
143
+ }
144
+
145
+ def permission(self, args, ctx):
146
+ return Request("Read", self.name, paths=[ctx.resolve(require(args, "path"))])
147
+
148
+ def describe(self, args, ctx):
149
+ s = ctx.rel(ctx.resolve(args.get("path", "")))
150
+ if args.get("offset") or args.get("limit"):
151
+ s += f" (from line {args.get('offset') or 1}, {args.get('limit') or DEFAULT_READ_LIMIT} lines)"
152
+ return s
153
+
154
+ def run(self, args, ctx):
155
+ path = ctx.resolve(require(args, "path"))
156
+ offset = max(1, opt_int(args, "offset", 1) or 1)
157
+ limit = max(1, min(opt_int(args, "limit", DEFAULT_READ_LIMIT) or DEFAULT_READ_LIMIT, 2000))
158
+ rng = (offset, limit)
159
+ if ctx.tracker.already_has(path, rng):
160
+ return ToolResult(
161
+ f"{ctx.rel(path)} has not changed since you read lines {offset}-{offset + limit - 1} "
162
+ "earlier in this conversation. Use that earlier output.",
163
+ preview="(unchanged since last read)",
164
+ )
165
+ tf = load_text(path)
166
+ lines = tf.text.replace("\r\n", "\n").split("\n")
167
+ if lines and lines[-1] == "":
168
+ lines.pop()
169
+ total = len(lines)
170
+ if total == 0:
171
+ ctx.tracker.mark_read(path, rng)
172
+ return ToolResult(f"{ctx.rel(path)} is empty.")
173
+ if offset > total:
174
+ raise ToolError(f"offset {offset} is past the end of the file ({total} lines).")
175
+ chunk = lines[offset - 1: offset - 1 + limit]
176
+ out = []
177
+ for i, ln in enumerate(chunk, start=offset):
178
+ if len(ln) > MAX_LINE_CHARS:
179
+ ln = ln[:MAX_LINE_CHARS] + f"... [line truncated, {len(ln)} chars]"
180
+ out.append(f"{i:>6}\t{ln}")
181
+ end = offset + len(chunk) - 1
182
+ body = "\n".join(out)
183
+ if offset > 1 or end < total:
184
+ body += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset/limit to read more.)"
185
+ ctx.tracker.mark_read(path, rng)
186
+ return ToolResult(truncate(body, ctx.max_output_chars, "read a smaller range"),
187
+ preview=f"read {len(chunk)} lines ({offset}-{end} of {total})")
188
+
189
+
190
+ # ---------------------------------------------------------------- write_file
191
+
192
+ class WriteFile(Tool):
193
+ name = "write_file"
194
+ description = (
195
+ "Create a new file or completely replace an existing one. For changes to an existing file "
196
+ "prefer edit_file. You must read_file an existing file before overwriting it."
197
+ )
198
+ parameters = {
199
+ "type": "object",
200
+ "properties": {
201
+ "path": {"type": "string", "description": "File path relative to the project root."},
202
+ "content": {"type": "string", "description": "The full file content."},
203
+ },
204
+ "required": ["path", "content"],
205
+ }
206
+
207
+ def permission(self, args, ctx):
208
+ return Request("Edit", self.name, paths=[ctx.resolve(require(args, "path"))])
209
+
210
+ def describe(self, args, ctx):
211
+ p = ctx.resolve(args.get("path", ""))
212
+ return ("overwrite " if p.exists() else "create ") + ctx.rel(p)
213
+
214
+ def _check(self, args, ctx):
215
+ path = ctx.resolve(require(args, "path"))
216
+ content = require(args, "content")
217
+ old = ""
218
+ bom = False
219
+ if path.exists():
220
+ if path.is_dir():
221
+ raise ToolError(f"{path} is a directory.")
222
+ if not ctx.tracker.was_read(path):
223
+ raise ToolError(f"{ctx.rel(path)} already exists. Read it with read_file before overwriting it.")
224
+ if not ctx.tracker.unchanged_since_read(path):
225
+ raise ToolError(f"{ctx.rel(path)} changed since you last read it. Read it again first.")
226
+ tf = load_text(path)
227
+ old, bom = tf.text, tf.bom
228
+ if tf.crlf and "\r\n" not in content:
229
+ content = content.replace("\n", "\r\n")
230
+ return path, old, content, bom
231
+
232
+ def preview(self, args, ctx):
233
+ path, old, content, _ = self._check(args, ctx)
234
+ return make_diff(old, content, ctx.rel(path))
235
+
236
+ def run(self, args, ctx):
237
+ path, old, content, bom = self._check(args, ctx)
238
+ existed = path.exists()
239
+ save_text(path, TextFile(content, bom))
240
+ ctx.tracker.mark_written(path)
241
+ n = content.count("\n") + (0 if content.endswith("\n") or not content else 1)
242
+ verb = "Updated" if existed else "Created"
243
+ return ToolResult(f"{verb} {ctx.rel(path)} ({n} lines).")
244
+
245
+
246
+ # ---------------------------------------------------------------- edit_file / multi_edit
247
+
248
+ class EditFile(Tool):
249
+ name = "edit_file"
250
+ description = (
251
+ "Replace an exact string in a file. old_string must match the file exactly (including "
252
+ "indentation) and must be unique unless replace_all is true. Do not include the line-number "
253
+ "prefix from read_file. Read the file first."
254
+ )
255
+ parameters = {
256
+ "type": "object",
257
+ "properties": {
258
+ "path": {"type": "string"},
259
+ "old_string": {"type": "string", "description": "Exact text to replace."},
260
+ "new_string": {"type": "string", "description": "Replacement text."},
261
+ "replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)."},
262
+ },
263
+ "required": ["path", "old_string", "new_string"],
264
+ }
265
+
266
+ def permission(self, args, ctx):
267
+ return Request("Edit", self.name, paths=[ctx.resolve(require(args, "path"))])
268
+
269
+ def describe(self, args, ctx):
270
+ return ctx.rel(ctx.resolve(args.get("path", "")))
271
+
272
+ def edits(self, args) -> List[Dict[str, Any]]:
273
+ return [{"old_string": require(args, "old_string"), "new_string": require(args, "new_string"),
274
+ "replace_all": bool(args.get("replace_all"))}]
275
+
276
+ def _compute(self, args, ctx):
277
+ path = ctx.resolve(require(args, "path"))
278
+ if not path.exists():
279
+ raise ToolError(f"File not found: {ctx.rel(path)}. Use write_file to create it.")
280
+ if not ctx.tracker.was_read(path):
281
+ raise ToolError(f"Read {ctx.rel(path)} with read_file before editing it.")
282
+ if not ctx.tracker.unchanged_since_read(path):
283
+ raise ToolError(f"{ctx.rel(path)} changed since you last read it. Read it again first.")
284
+ tf = load_text(path)
285
+ text = tf.text
286
+ total = 0
287
+ for i, e in enumerate(self.edits(args), start=1):
288
+ if not isinstance(e, dict):
289
+ raise ToolError(f"edit {i} must be an object.")
290
+ try:
291
+ text, n = apply_edit(text, e.get("old_string", ""), e.get("new_string", ""),
292
+ bool(e.get("replace_all")))
293
+ except ToolError as err:
294
+ prefix = f"Edit {i} failed: " if self.name == "multi_edit" else ""
295
+ raise ToolError(prefix + str(err) + (" No edits were applied." if self.name == "multi_edit" else ""))
296
+ total += n
297
+ return path, tf, text, total
298
+
299
+ def preview(self, args, ctx):
300
+ path, tf, text, _ = self._compute(args, ctx)
301
+ return make_diff(tf.text, text, ctx.rel(path))
302
+
303
+ def run(self, args, ctx):
304
+ path, tf, text, total = self._compute(args, ctx)
305
+ diff = make_diff(tf.text, text, ctx.rel(path))
306
+ save_text(path, TextFile(text, tf.bom))
307
+ ctx.tracker.mark_written(path)
308
+ added = sum(1 for ln in diff.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
309
+ removed = sum(1 for ln in diff.splitlines() if ln.startswith("-") and not ln.startswith("---"))
310
+ return ToolResult(
311
+ f"Edited {ctx.rel(path)}: {total} replacement(s), +{added} -{removed} lines.\n"
312
+ + truncate(diff, 3000, "diff shortened"),
313
+ preview=f"+{added} -{removed} lines",
314
+ )
315
+
316
+
317
+ class MultiEdit(EditFile):
318
+ name = "multi_edit"
319
+ description = (
320
+ "Apply several exact-string edits to ONE file in order, atomically: if any edit fails, none "
321
+ "are applied. Each edit sees the result of the previous one. Read the file first."
322
+ )
323
+ parameters = {
324
+ "type": "object",
325
+ "properties": {
326
+ "path": {"type": "string"},
327
+ "edits": {
328
+ "type": "array",
329
+ "items": {
330
+ "type": "object",
331
+ "properties": {
332
+ "old_string": {"type": "string"},
333
+ "new_string": {"type": "string"},
334
+ "replace_all": {"type": "boolean"},
335
+ },
336
+ "required": ["old_string", "new_string"],
337
+ },
338
+ },
339
+ },
340
+ "required": ["path", "edits"],
341
+ }
342
+
343
+ def edits(self, args):
344
+ edits = args.get("edits")
345
+ if not isinstance(edits, list) or not edits:
346
+ raise ToolError("`edits` must be a non-empty list.")
347
+ return edits
348
+
349
+ def describe(self, args, ctx):
350
+ n = len(args.get("edits") or [])
351
+ return f"{ctx.rel(ctx.resolve(args.get('path', '')))} ({n} edits)"
352
+
353
+
354
+ # ---------------------------------------------------------------- list_dir
355
+
356
+ class ListDir(Tool):
357
+ name = "list_dir"
358
+ read_only = True
359
+ description = "List a directory as a tree (directories end with /). Skips .git, node_modules, venvs and git-ignored files."
360
+ parameters = {
361
+ "type": "object",
362
+ "properties": {
363
+ "path": {"type": "string", "description": "Directory (default: project root)."},
364
+ "depth": {"type": "integer", "description": "How many levels to show, 1-4 (default 2)."},
365
+ },
366
+ }
367
+
368
+ def permission(self, args, ctx):
369
+ return Request("Read", self.name, paths=[ctx.resolve(args.get("path") or ".")])
370
+
371
+ def describe(self, args, ctx):
372
+ return ctx.rel(ctx.resolve(args.get("path") or ".")) or "."
373
+
374
+ def run(self, args, ctx):
375
+ base = ctx.resolve(args.get("path") or ".")
376
+ depth = max(1, min(opt_int(args, "depth", 2) or 2, 4))
377
+ if not base.exists():
378
+ raise ToolError(f"Directory not found: {base}")
379
+ if not base.is_dir():
380
+ raise ToolError(f"{base} is a file. Use read_file.")
381
+ gi = GitIgnore(ctx.root)
382
+ out: List[str] = []
383
+ limit = 400
384
+
385
+ def walk(d: Path, level: int) -> None:
386
+ try:
387
+ entries = sorted(d.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
388
+ except OSError as e:
389
+ out.append(" " * level + f"[cannot list: {e}]")
390
+ return
391
+ for p in entries:
392
+ if len(out) >= limit:
393
+ return
394
+ is_dir = p.is_dir()
395
+ try:
396
+ rel = p.relative_to(ctx.root).as_posix()
397
+ except ValueError:
398
+ rel = p.name
399
+ if is_dir and p.name in ALWAYS_SKIP_DIRS:
400
+ out.append(" " * level + p.name + "/ (skipped)")
401
+ continue
402
+ if gi.ignored(rel, is_dir):
403
+ continue
404
+ out.append(" " * level + p.name + ("/" if is_dir else ""))
405
+ if is_dir and level + 1 < depth:
406
+ walk(p, level + 1)
407
+
408
+ walk(base, 0)
409
+ if len(out) >= limit:
410
+ out.append(f"[... stopped at {limit} entries; list a subdirectory for more]")
411
+ body = "\n".join(out) if out else "(empty directory)"
412
+ return ToolResult(truncate(body, ctx.max_output_chars), preview=f"{len(out)} entries")
@@ -0,0 +1,75 @@
1
+ """Best-effort ignore rules for the Python fallbacks of glob/grep/list_dir."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Iterator, List, Tuple
9
+
10
+ ALWAYS_SKIP_DIRS = {
11
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", "env", ".tox",
12
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".next", ".nuxt", "dist", "build",
13
+ ".idea", ".gradle", "target", ".cache", "coverage", ".turbo", ".parcel-cache",
14
+ }
15
+
16
+
17
+ class GitIgnore:
18
+ """A small subset of .gitignore: root .gitignore, simple globs, dir patterns, negation ignored."""
19
+
20
+ def __init__(self, root: Path):
21
+ self.root = root
22
+ self.patterns: List[Tuple[str, bool]] = [] # (pattern, dir_only)
23
+ try:
24
+ with open(root / ".gitignore", "r", encoding="utf-8", errors="replace") as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if not line or line.startswith("#") or line.startswith("!"):
28
+ continue
29
+ dir_only = line.endswith("/")
30
+ self.patterns.append((line.strip("/"), dir_only))
31
+ except OSError:
32
+ pass
33
+
34
+ def ignored(self, rel_posix: str, is_dir: bool) -> bool:
35
+ name = rel_posix.rsplit("/", 1)[-1]
36
+ for pat, dir_only in self.patterns:
37
+ if dir_only and not is_dir:
38
+ continue
39
+ if "/" in pat:
40
+ if fnmatch.fnmatch(rel_posix, pat.lstrip("/")) or fnmatch.fnmatch(rel_posix, pat.lstrip("/") + "/*"):
41
+ return True
42
+ elif fnmatch.fnmatch(name, pat):
43
+ return True
44
+ return False
45
+
46
+
47
+ def walk_files(base: Path, root: Path) -> Iterator[Path]:
48
+ gi = GitIgnore(root)
49
+ for dirpath, dirnames, filenames in os.walk(base):
50
+ dp = Path(dirpath)
51
+ keep = []
52
+ for d in dirnames:
53
+ if d in ALWAYS_SKIP_DIRS:
54
+ continue
55
+ try:
56
+ rel = (dp / d).relative_to(root).as_posix()
57
+ except ValueError:
58
+ rel = d
59
+ if gi.ignored(rel, True):
60
+ continue
61
+ keep.append(d)
62
+ dirnames[:] = sorted(keep)
63
+ for fn in sorted(filenames):
64
+ p = dp / fn
65
+ try:
66
+ rel = p.relative_to(root).as_posix()
67
+ except ValueError:
68
+ rel = fn
69
+ if gi.ignored(rel, False):
70
+ continue
71
+ yield p
72
+
73
+
74
+ def is_binary_bytes(data: bytes) -> bool:
75
+ return b"\x00" in data[:8192]
@@ -0,0 +1,204 @@
1
+ """glob and grep. grep uses ripgrep when installed, else a Python fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from euron_code.permissions import Request
13
+ from euron_code.tools.base import Tool, ToolError, ToolResult, opt_int, require, truncate
14
+ from euron_code.tools.ignore import is_binary_bytes, walk_files
15
+
16
+
17
+ def ripgrep_path():
18
+ return shutil.which("rg")
19
+
20
+
21
+ def _glob_match(rel: str, pattern: str) -> bool:
22
+ pat = pattern.replace("\\", "/")
23
+ if pat.startswith("./"):
24
+ pat = pat[2:]
25
+ if fnmatch.fnmatch(rel, pat):
26
+ return True
27
+ # `**/x` should also match `x` at the top level
28
+ if pat.startswith("**/") and fnmatch.fnmatch(rel, pat[3:]):
29
+ return True
30
+ # a pattern without a slash matches the basename anywhere (like `*.py`)
31
+ if "/" not in pat and fnmatch.fnmatch(rel.rsplit("/", 1)[-1], pat):
32
+ return True
33
+ return False
34
+
35
+
36
+ class Glob(Tool):
37
+ name = "glob"
38
+ read_only = True
39
+ description = (
40
+ "Find files by glob pattern, e.g. `**/*.py` or `src/**/test_*.ts`. Results are relative paths, "
41
+ "newest first, skipping .git, node_modules, venvs and git-ignored files."
42
+ )
43
+ parameters = {
44
+ "type": "object",
45
+ "properties": {
46
+ "pattern": {"type": "string"},
47
+ "path": {"type": "string", "description": "Directory to search in (default: project root)."},
48
+ },
49
+ "required": ["pattern"],
50
+ }
51
+
52
+ def permission(self, args, ctx):
53
+ return Request("Read", self.name, paths=[ctx.resolve(args.get("path") or ".")])
54
+
55
+ def describe(self, args, ctx):
56
+ return args.get("pattern", "")
57
+
58
+ def run(self, args, ctx):
59
+ pattern = require(args, "pattern")
60
+ base = ctx.resolve(args.get("path") or ".")
61
+ if not base.is_dir():
62
+ raise ToolError(f"Not a directory: {base}")
63
+ hits = []
64
+ for p in walk_files(base, ctx.root):
65
+ try:
66
+ rel = p.relative_to(base).as_posix()
67
+ except ValueError:
68
+ continue
69
+ if _glob_match(rel, pattern):
70
+ try:
71
+ hits.append((p.stat().st_mtime, p))
72
+ except OSError:
73
+ continue
74
+ if len(hits) > 5000:
75
+ break
76
+ hits.sort(key=lambda t: -t[0])
77
+ limit = 200
78
+ lines = [ctx.rel(p) for _, p in hits[:limit]]
79
+ if not lines:
80
+ return ToolResult(f"No files match {pattern!r}.", preview="0 files")
81
+ body = "\n".join(lines)
82
+ if len(hits) > limit:
83
+ body += f"\n[... {len(hits) - limit} more files; narrow the pattern]"
84
+ return ToolResult(truncate(body, ctx.max_output_chars), preview=f"{len(hits)} files")
85
+
86
+
87
+ class Grep(Tool):
88
+ name = "grep"
89
+ read_only = True
90
+ description = (
91
+ "Search file contents with a regular expression. output_mode: `content` (matching lines with "
92
+ "file:line), `files_with_matches` (default), or `count`. Use `glob` (e.g. `*.py`) to filter files."
93
+ )
94
+ parameters = {
95
+ "type": "object",
96
+ "properties": {
97
+ "pattern": {"type": "string", "description": "Regular expression."},
98
+ "path": {"type": "string", "description": "File or directory (default: project root)."},
99
+ "glob": {"type": "string", "description": "Only search files matching this glob."},
100
+ "output_mode": {"type": "string", "enum": ["content", "files_with_matches", "count"]},
101
+ "ignore_case": {"type": "boolean"},
102
+ "context": {"type": "integer", "description": "Lines of context around matches (content mode)."},
103
+ "head_limit": {"type": "integer", "description": "Max lines of output (default 200)."},
104
+ },
105
+ "required": ["pattern"],
106
+ }
107
+
108
+ def permission(self, args, ctx):
109
+ return Request("Read", self.name, paths=[ctx.resolve(args.get("path") or ".")])
110
+
111
+ def describe(self, args, ctx):
112
+ s = repr(args.get("pattern", ""))
113
+ if args.get("glob"):
114
+ s += f" in {args['glob']}"
115
+ return s
116
+
117
+ def run(self, args, ctx):
118
+ pattern = require(args, "pattern")
119
+ base = ctx.resolve(args.get("path") or ".")
120
+ mode = args.get("output_mode") or "files_with_matches"
121
+ if mode not in ("content", "files_with_matches", "count"):
122
+ raise ToolError("output_mode must be content, files_with_matches or count.")
123
+ head = max(1, opt_int(args, "head_limit", 200) or 200)
124
+ context = max(0, min(opt_int(args, "context", 0) or 0, 10))
125
+ icase = bool(args.get("ignore_case"))
126
+ glob = args.get("glob")
127
+ if not base.exists():
128
+ raise ToolError(f"Path not found: {base}")
129
+ rg = ripgrep_path()
130
+ if rg and not ctx.settings.get("_forcePythonGrep"):
131
+ lines = self._rg(rg, pattern, base, ctx, mode, icase, glob, context)
132
+ else:
133
+ lines = self._py(pattern, base, ctx, mode, icase, glob, context)
134
+ if not lines:
135
+ return ToolResult(f"No matches for {pattern!r}.", preview="no matches")
136
+ total = len(lines)
137
+ body = "\n".join(lines[:head])
138
+ if total > head:
139
+ body += f"\n[... {total - head} more lines; refine the pattern or raise head_limit]"
140
+ return ToolResult(truncate(body, ctx.max_output_chars), preview=f"{total} result lines")
141
+
142
+ def _rg(self, rg, pattern, base, ctx, mode, icase, glob, context) -> List[str]:
143
+ cmd = [rg, "--color", "never", "--no-heading", "--no-messages"]
144
+ if icase:
145
+ cmd.append("-i")
146
+ if glob:
147
+ cmd += ["-g", glob]
148
+ if mode == "files_with_matches":
149
+ cmd.append("-l")
150
+ elif mode == "count":
151
+ cmd.append("-c")
152
+ else:
153
+ cmd.append("-n")
154
+ if context:
155
+ cmd += ["-C", str(context)]
156
+ target = ctx.rel(base) # relative inside the project, absolute outside
157
+ cmd += ["-e", pattern, "--", target or "."]
158
+ try:
159
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
160
+ errors="replace", timeout=60, cwd=str(ctx.root))
161
+ except subprocess.TimeoutExpired:
162
+ raise ToolError("grep timed out after 60 s; narrow the path or pattern.")
163
+ if proc.returncode not in (0, 1):
164
+ raise ToolError(f"ripgrep error: {proc.stderr.strip()[:500]}")
165
+ return proc.stdout.splitlines()
166
+
167
+ def _py(self, pattern, base, ctx, mode, icase, glob, context) -> List[str]:
168
+ try:
169
+ rx = re.compile(pattern, re.IGNORECASE if icase else 0)
170
+ except re.error as e:
171
+ raise ToolError(f"Invalid regular expression: {e}")
172
+ files = [base] if base.is_file() else list(walk_files(base, ctx.root))
173
+ out: List[str] = []
174
+ for p in files:
175
+ if glob and not _glob_match(p.name if "/" not in glob else ctx.rel(p), glob):
176
+ continue
177
+ try:
178
+ data = p.read_bytes()
179
+ except OSError:
180
+ continue
181
+ if len(data) > 5 * 1024 * 1024 or is_binary_bytes(data):
182
+ continue
183
+ text = data.decode("utf-8", errors="replace")
184
+ lines = text.splitlines()
185
+ idx = [i for i, ln in enumerate(lines) if rx.search(ln)]
186
+ if not idx:
187
+ continue
188
+ rel = ctx.rel(p)
189
+ if mode == "files_with_matches":
190
+ out.append(rel)
191
+ elif mode == "count":
192
+ out.append(f"{rel}:{len(idx)}")
193
+ else:
194
+ shown = set()
195
+ for i in idx:
196
+ for j in range(max(0, i - context), min(len(lines), i + context + 1)):
197
+ if j in shown:
198
+ continue
199
+ shown.add(j)
200
+ sep = ":" if j == i or j in idx else "-"
201
+ out.append(f"{rel}{sep}{j + 1}{sep}{lines[j][:500]}")
202
+ if len(out) > 5000:
203
+ break
204
+ return out