crabcode 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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""FileEditTool — string replacement editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
|
|
9
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileEditTool(Tool):
|
|
13
|
+
name = "Edit"
|
|
14
|
+
description = "Edit a file by replacing a specific string."
|
|
15
|
+
is_read_only = False
|
|
16
|
+
is_concurrency_safe = False
|
|
17
|
+
input_schema = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"file_path": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The path of the file to edit.",
|
|
23
|
+
},
|
|
24
|
+
"old_string": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "The exact string to find and replace.",
|
|
27
|
+
},
|
|
28
|
+
"new_string": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "The replacement string.",
|
|
31
|
+
},
|
|
32
|
+
"replace_all": {
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"description": "Replace all occurrences (default: false).",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
41
|
+
return (
|
|
42
|
+
"Edit a file by performing exact string replacements. "
|
|
43
|
+
"The old_string must uniquely identify the text to replace — "
|
|
44
|
+
"if it matches multiple locations, the edit will FAIL. Include "
|
|
45
|
+
"surrounding context lines to make the match unique. Preserves "
|
|
46
|
+
"exact indentation (tabs/spaces) as it appears in the file. "
|
|
47
|
+
"Use replace_all=true to replace all occurrences (useful for "
|
|
48
|
+
"renaming a variable across the file).\n\n"
|
|
49
|
+
"IMPORTANT: You MUST read the file before editing it. Never "
|
|
50
|
+
"guess the file content.\n\n"
|
|
51
|
+
"GOOD example — unique match with context:\n"
|
|
52
|
+
' old_string: "def calculate(a, b):\\n return a + b"\n'
|
|
53
|
+
' new_string: "def calculate(a, b):\\n return a * b"\n\n'
|
|
54
|
+
"BAD example — too short, likely matches multiple locations:\n"
|
|
55
|
+
' old_string: "return"\n'
|
|
56
|
+
' new_string: "return None"'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
60
|
+
if tool_input.get("old_string") == tool_input.get("new_string"):
|
|
61
|
+
return "old_string and new_string must be different"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
async def call(
|
|
65
|
+
self,
|
|
66
|
+
tool_input: dict[str, Any],
|
|
67
|
+
context: ToolContext,
|
|
68
|
+
) -> ToolResult:
|
|
69
|
+
file_path = tool_input.get("file_path", "")
|
|
70
|
+
old_string = tool_input.get("old_string", "")
|
|
71
|
+
new_string = tool_input.get("new_string", "")
|
|
72
|
+
replace_all = tool_input.get("replace_all", False)
|
|
73
|
+
|
|
74
|
+
if not file_path:
|
|
75
|
+
return ToolResult(
|
|
76
|
+
result_for_model="Error: file_path is required",
|
|
77
|
+
is_error=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
path = Path(file_path)
|
|
81
|
+
if not path.is_absolute():
|
|
82
|
+
path = Path(context.cwd) / path
|
|
83
|
+
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return ToolResult(
|
|
86
|
+
result_for_model=f"Error: file not found: {path}",
|
|
87
|
+
is_error=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
content = path.read_text(errors="replace")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return ToolResult(
|
|
94
|
+
result_for_model=f"Error reading file: {e}",
|
|
95
|
+
is_error=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
count = content.count(old_string)
|
|
99
|
+
|
|
100
|
+
if count == 0:
|
|
101
|
+
return ToolResult(
|
|
102
|
+
result_for_model=f"Error: old_string not found in {path}",
|
|
103
|
+
is_error=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if count > 1 and not replace_all:
|
|
107
|
+
return ToolResult(
|
|
108
|
+
result_for_model=(
|
|
109
|
+
f"Error: old_string found {count} times in {path}. "
|
|
110
|
+
"Use replace_all=true to replace all occurrences, "
|
|
111
|
+
"or provide more context to make the match unique."
|
|
112
|
+
),
|
|
113
|
+
is_error=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if replace_all:
|
|
117
|
+
new_content = content.replace(old_string, new_string)
|
|
118
|
+
else:
|
|
119
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
path.write_text(new_content)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return ToolResult(
|
|
125
|
+
result_for_model=f"Error writing file: {e}",
|
|
126
|
+
is_error=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
replaced = count if replace_all else 1
|
|
130
|
+
|
|
131
|
+
diff_info = compute_diff(content, new_content, str(path))
|
|
132
|
+
model_msg, display_msg = format_edit_summary(
|
|
133
|
+
str(path), diff_info, replacements=replaced
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return ToolResult(
|
|
137
|
+
data={
|
|
138
|
+
"file_path": str(path),
|
|
139
|
+
"replacements": replaced,
|
|
140
|
+
"line_range": diff_info["line_range"],
|
|
141
|
+
"stats": diff_info["stats"],
|
|
142
|
+
},
|
|
143
|
+
result_for_model=model_msg,
|
|
144
|
+
result_for_display=display_msg,
|
|
145
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""FileReadTool — read file contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileReadTool(Tool):
|
|
12
|
+
name = "Read"
|
|
13
|
+
description = "Read the contents of a file."
|
|
14
|
+
is_read_only = True
|
|
15
|
+
is_concurrency_safe = True
|
|
16
|
+
input_schema = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"file_path": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "The absolute or relative path of the file to read.",
|
|
22
|
+
},
|
|
23
|
+
"offset": {
|
|
24
|
+
"type": "integer",
|
|
25
|
+
"description": "Line number to start reading from (1-based). Negative values count from end.",
|
|
26
|
+
},
|
|
27
|
+
"limit": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"description": "Number of lines to read.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
"required": ["file_path"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
36
|
+
return (
|
|
37
|
+
"Read the contents of a file from the local filesystem. "
|
|
38
|
+
"You can optionally specify a line offset and limit (especially "
|
|
39
|
+
"useful for large files), but it's recommended to read the whole "
|
|
40
|
+
"file by not providing these parameters.\n\n"
|
|
41
|
+
"Lines in the output are numbered starting at 1, in the format "
|
|
42
|
+
"LINE_NUMBER|LINE_CONTENT. The LINE_NUMBER prefix is metadata — "
|
|
43
|
+
"do NOT treat it as part of the actual code.\n\n"
|
|
44
|
+
"IMPORTANT: You MUST read a file at least once before editing it. "
|
|
45
|
+
"You can call Read on multiple files in a single response to read "
|
|
46
|
+
"them in parallel.\n\n"
|
|
47
|
+
"If the file exists but is empty, you will receive 'File is empty.'.\n"
|
|
48
|
+
"If the file does not exist, you will receive an error."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def call(
|
|
52
|
+
self,
|
|
53
|
+
tool_input: dict[str, Any],
|
|
54
|
+
context: ToolContext,
|
|
55
|
+
) -> ToolResult:
|
|
56
|
+
file_path = tool_input.get("file_path", "")
|
|
57
|
+
offset = tool_input.get("offset")
|
|
58
|
+
limit = tool_input.get("limit")
|
|
59
|
+
|
|
60
|
+
if not file_path:
|
|
61
|
+
return ToolResult(
|
|
62
|
+
result_for_model="Error: file_path is required",
|
|
63
|
+
is_error=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
path = Path(file_path)
|
|
67
|
+
if not path.is_absolute():
|
|
68
|
+
path = Path(context.cwd) / path
|
|
69
|
+
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return ToolResult(
|
|
72
|
+
result_for_model=f"Error: file not found: {path}",
|
|
73
|
+
is_error=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
return ToolResult(
|
|
78
|
+
result_for_model=f"Error: not a file: {path}",
|
|
79
|
+
is_error=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
content = path.read_text(errors="replace")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return ToolResult(
|
|
86
|
+
result_for_model=f"Error reading file: {e}",
|
|
87
|
+
is_error=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if not content:
|
|
91
|
+
return ToolResult(
|
|
92
|
+
result_for_model="File is empty.",
|
|
93
|
+
data={"file_path": str(path), "content": ""},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
lines = content.splitlines(keepends=True)
|
|
97
|
+
total_lines = len(lines)
|
|
98
|
+
|
|
99
|
+
if offset is not None:
|
|
100
|
+
if offset < 0:
|
|
101
|
+
start = max(0, total_lines + offset)
|
|
102
|
+
else:
|
|
103
|
+
start = max(0, offset - 1)
|
|
104
|
+
else:
|
|
105
|
+
start = 0
|
|
106
|
+
|
|
107
|
+
if limit is not None:
|
|
108
|
+
end = min(total_lines, start + limit)
|
|
109
|
+
else:
|
|
110
|
+
end = total_lines
|
|
111
|
+
|
|
112
|
+
selected = lines[start:end]
|
|
113
|
+
|
|
114
|
+
numbered = []
|
|
115
|
+
for i, line in enumerate(selected, start=start + 1):
|
|
116
|
+
numbered.append(f"{i:6d}|{line.rstrip()}")
|
|
117
|
+
|
|
118
|
+
result_text = "\n".join(numbered)
|
|
119
|
+
|
|
120
|
+
return ToolResult(
|
|
121
|
+
data={"file_path": str(path), "content": content, "total_lines": total_lines},
|
|
122
|
+
result_for_model=result_text,
|
|
123
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""FileWriteTool — create or overwrite files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
|
|
9
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileWriteTool(Tool):
|
|
13
|
+
name = "Write"
|
|
14
|
+
description = "Write content to a file, creating it if necessary."
|
|
15
|
+
is_read_only = False
|
|
16
|
+
is_concurrency_safe = False
|
|
17
|
+
input_schema = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"file_path": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The path of the file to write.",
|
|
23
|
+
},
|
|
24
|
+
"content": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "The content to write to the file.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"required": ["file_path", "content"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
33
|
+
return (
|
|
34
|
+
"Write content to a file. Creates the file and parent directories "
|
|
35
|
+
"if they don't exist. WARNING: This will overwrite the existing "
|
|
36
|
+
"file if there is one.\n\n"
|
|
37
|
+
"ALWAYS prefer editing existing files with the Edit tool instead "
|
|
38
|
+
"of rewriting them. Only use Write for:\n"
|
|
39
|
+
"- Creating new files that don't exist yet\n"
|
|
40
|
+
"- Cases where the entire file content needs to be replaced\n\n"
|
|
41
|
+
"NEVER create files unless absolutely necessary for achieving "
|
|
42
|
+
"your goal. This prevents file bloat and builds on existing "
|
|
43
|
+
"work more effectively.\n"
|
|
44
|
+
"NEVER proactively create documentation or README files unless "
|
|
45
|
+
"the user explicitly requests them."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def call(
|
|
49
|
+
self,
|
|
50
|
+
tool_input: dict[str, Any],
|
|
51
|
+
context: ToolContext,
|
|
52
|
+
) -> ToolResult:
|
|
53
|
+
file_path = tool_input.get("file_path", "")
|
|
54
|
+
content = tool_input.get("content", "")
|
|
55
|
+
|
|
56
|
+
if not file_path:
|
|
57
|
+
return ToolResult(
|
|
58
|
+
result_for_model="Error: file_path is required",
|
|
59
|
+
is_error=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
path = Path(file_path)
|
|
63
|
+
if not path.is_absolute():
|
|
64
|
+
path = Path(context.cwd) / path
|
|
65
|
+
|
|
66
|
+
is_new = not path.exists()
|
|
67
|
+
old_content = ""
|
|
68
|
+
if not is_new:
|
|
69
|
+
try:
|
|
70
|
+
old_content = path.read_text(errors="replace")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(content)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return ToolResult(
|
|
79
|
+
result_for_model=f"Error writing file: {e}",
|
|
80
|
+
is_error=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if is_new:
|
|
84
|
+
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
85
|
+
return ToolResult(
|
|
86
|
+
data={"file_path": str(path), "created": True, "lines": line_count},
|
|
87
|
+
result_for_model=f"Created {path} ({line_count} lines).",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
diff_info = compute_diff(old_content, content, str(path))
|
|
91
|
+
model_msg, display_msg = format_edit_summary(str(path), diff_info)
|
|
92
|
+
|
|
93
|
+
return ToolResult(
|
|
94
|
+
data={
|
|
95
|
+
"file_path": str(path),
|
|
96
|
+
"created": False,
|
|
97
|
+
"line_range": diff_info["line_range"],
|
|
98
|
+
"stats": diff_info["stats"],
|
|
99
|
+
},
|
|
100
|
+
result_for_model=model_msg,
|
|
101
|
+
result_for_display=display_msg,
|
|
102
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""GlobTool — find files by pattern."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GlobTool(Tool):
|
|
14
|
+
name = "Glob"
|
|
15
|
+
description = "Find files matching a glob pattern."
|
|
16
|
+
is_read_only = True
|
|
17
|
+
is_concurrency_safe = True
|
|
18
|
+
input_schema = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"pattern": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Glob pattern to match (e.g., '**/*.py', 'src/**/*.ts').",
|
|
24
|
+
},
|
|
25
|
+
"path": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Directory to search in (default: cwd).",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"required": ["pattern"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
34
|
+
return (
|
|
35
|
+
"Find files matching a glob pattern. Returns matching file paths "
|
|
36
|
+
"sorted by modification time (most recent first). Works fast "
|
|
37
|
+
"with codebases of any size.\n\n"
|
|
38
|
+
"Patterns not starting with '**/' are automatically prepended "
|
|
39
|
+
"with it for recursive searching. Use this instead of find or "
|
|
40
|
+
"ls via Bash.\n\n"
|
|
41
|
+
"Examples:\n"
|
|
42
|
+
'- "*.py" (becomes "**/*.py") — find all Python files\n'
|
|
43
|
+
'- "src/**/*.ts" — find all TypeScript files under src/\n'
|
|
44
|
+
'- "**/test_*.py" — find all test files'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
async def call(
|
|
48
|
+
self,
|
|
49
|
+
tool_input: dict[str, Any],
|
|
50
|
+
context: ToolContext,
|
|
51
|
+
) -> ToolResult:
|
|
52
|
+
pattern = tool_input.get("pattern", "")
|
|
53
|
+
search_path = tool_input.get("path", context.cwd)
|
|
54
|
+
|
|
55
|
+
if not pattern:
|
|
56
|
+
return ToolResult(
|
|
57
|
+
result_for_model="Error: pattern is required",
|
|
58
|
+
is_error=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if not pattern.startswith("**/") and not pattern.startswith("/"):
|
|
62
|
+
pattern = f"**/{pattern}"
|
|
63
|
+
|
|
64
|
+
root = Path(search_path)
|
|
65
|
+
if not root.is_absolute():
|
|
66
|
+
root = Path(context.cwd) / root
|
|
67
|
+
|
|
68
|
+
if not root.exists():
|
|
69
|
+
return ToolResult(
|
|
70
|
+
result_for_model=f"Error: directory not found: {root}",
|
|
71
|
+
is_error=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
matches: list[tuple[float, str]] = []
|
|
76
|
+
for path in root.glob(pattern):
|
|
77
|
+
if path.is_file():
|
|
78
|
+
try:
|
|
79
|
+
mtime = path.stat().st_mtime
|
|
80
|
+
except OSError:
|
|
81
|
+
mtime = 0
|
|
82
|
+
matches.append((mtime, str(path)))
|
|
83
|
+
|
|
84
|
+
if len(matches) > 1000:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
matches.sort(key=lambda x: x[0], reverse=True)
|
|
88
|
+
file_paths = [p for _, p in matches]
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return ToolResult(
|
|
92
|
+
result_for_model=f"Error during glob: {e}",
|
|
93
|
+
is_error=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if not file_paths:
|
|
97
|
+
return ToolResult(
|
|
98
|
+
result_for_model="No files matched the pattern.",
|
|
99
|
+
data={"count": 0},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
result_text = "\n".join(file_paths)
|
|
103
|
+
count = len(file_paths)
|
|
104
|
+
suffix = " (showing first 1000)" if count >= 1000 else ""
|
|
105
|
+
|
|
106
|
+
return ToolResult(
|
|
107
|
+
data={"count": count, "files": file_paths},
|
|
108
|
+
result_for_model=f"Found {count} files{suffix}:\n{result_text}",
|
|
109
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""GrepTool — search file contents using ripgrep."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GrepTool(Tool):
|
|
13
|
+
name = "Grep"
|
|
14
|
+
description = "Search file contents using regex patterns."
|
|
15
|
+
is_read_only = True
|
|
16
|
+
is_concurrency_safe = True
|
|
17
|
+
input_schema = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"pattern": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The regex pattern to search for.",
|
|
23
|
+
},
|
|
24
|
+
"path": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Directory or file to search in (default: cwd).",
|
|
27
|
+
},
|
|
28
|
+
"glob": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "File glob pattern to filter (e.g., '*.py').",
|
|
31
|
+
},
|
|
32
|
+
"case_insensitive": {
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"description": "Case insensitive search.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"required": ["pattern"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
41
|
+
return (
|
|
42
|
+
"Search file contents for a regex pattern using ripgrep. "
|
|
43
|
+
"Returns matching lines with file paths and line numbers. "
|
|
44
|
+
"Use this instead of running grep or rg via Bash.\n\n"
|
|
45
|
+
"Supports full regex syntax (e.g., 'log.*Error', "
|
|
46
|
+
"'function\\s+\\w+'). Filter files with the glob parameter "
|
|
47
|
+
"(e.g., '*.py', '*.tsx').\n\n"
|
|
48
|
+
"Results are capped at 200 matches for responsiveness. "
|
|
49
|
+
"If you need more specific results, narrow your search "
|
|
50
|
+
"with a more specific pattern or glob filter."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def call(
|
|
54
|
+
self,
|
|
55
|
+
tool_input: dict[str, Any],
|
|
56
|
+
context: ToolContext,
|
|
57
|
+
) -> ToolResult:
|
|
58
|
+
pattern = tool_input.get("pattern", "")
|
|
59
|
+
search_path = tool_input.get("path", context.cwd)
|
|
60
|
+
glob_pattern = tool_input.get("glob")
|
|
61
|
+
case_insensitive = tool_input.get("case_insensitive", False)
|
|
62
|
+
|
|
63
|
+
if not pattern:
|
|
64
|
+
return ToolResult(
|
|
65
|
+
result_for_model="Error: pattern is required",
|
|
66
|
+
is_error=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
rg = shutil.which("rg")
|
|
70
|
+
if not rg:
|
|
71
|
+
rg = "rg"
|
|
72
|
+
|
|
73
|
+
args = [rg, "--line-number", "--no-heading", "--color=never"]
|
|
74
|
+
|
|
75
|
+
if case_insensitive:
|
|
76
|
+
args.append("-i")
|
|
77
|
+
if glob_pattern:
|
|
78
|
+
args.extend(["--glob", glob_pattern])
|
|
79
|
+
|
|
80
|
+
args.extend(["--max-count", "200"])
|
|
81
|
+
args.append(pattern)
|
|
82
|
+
args.append("--")
|
|
83
|
+
args.append(search_path)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
proc = await asyncio.create_subprocess_exec(
|
|
87
|
+
*args,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
cwd=context.cwd,
|
|
91
|
+
)
|
|
92
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
93
|
+
proc.communicate(), timeout=30
|
|
94
|
+
)
|
|
95
|
+
except asyncio.TimeoutError:
|
|
96
|
+
return ToolResult(
|
|
97
|
+
result_for_model="Search timed out after 30s",
|
|
98
|
+
is_error=True,
|
|
99
|
+
)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
return ToolResult(
|
|
102
|
+
result_for_model="Error: ripgrep (rg) not found. Please install it.",
|
|
103
|
+
is_error=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
107
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
108
|
+
|
|
109
|
+
if proc.returncode == 1:
|
|
110
|
+
return ToolResult(
|
|
111
|
+
result_for_model="No matches found.",
|
|
112
|
+
data={"matches": 0},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if proc.returncode and proc.returncode > 1:
|
|
116
|
+
return ToolResult(
|
|
117
|
+
result_for_model=f"Grep error: {stderr or 'unknown error'}",
|
|
118
|
+
is_error=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
lines = stdout.strip().split("\n") if stdout.strip() else []
|
|
122
|
+
|
|
123
|
+
max_output = 50_000
|
|
124
|
+
if len(stdout) > max_output:
|
|
125
|
+
stdout = stdout[:max_output] + "\n... (truncated)"
|
|
126
|
+
|
|
127
|
+
return ToolResult(
|
|
128
|
+
data={"match_count": len(lines)},
|
|
129
|
+
result_for_model=stdout or "No matches found.",
|
|
130
|
+
)
|