minion-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.
- examples/advance_tui.py +508 -0
- examples/agent_with_todos.py +165 -0
- examples/file_freshness_example.py +97 -0
- examples/file_watching_example.py +110 -0
- examples/interruptible_tui.py +5 -0
- examples/message_response_children_demo.py +226 -0
- examples/rich_example.py +4 -0
- examples/simple_file_watching.py +57 -0
- examples/simple_tui.py +267 -0
- examples/simple_usage.py +69 -0
- minion_code/__init__.py +16 -0
- minion_code/agents/__init__.py +11 -0
- minion_code/agents/code_agent.py +320 -0
- minion_code/cli.py +502 -0
- minion_code/commands/__init__.py +90 -0
- minion_code/commands/clear_command.py +70 -0
- minion_code/commands/help_command.py +90 -0
- minion_code/commands/history_command.py +104 -0
- minion_code/commands/quit_command.py +32 -0
- minion_code/commands/status_command.py +115 -0
- minion_code/commands/tools_command.py +86 -0
- minion_code/commands/version_command.py +104 -0
- minion_code/components/Message.py +304 -0
- minion_code/components/MessageResponse.py +188 -0
- minion_code/components/PromptInput.py +534 -0
- minion_code/components/__init__.py +29 -0
- minion_code/screens/REPL.py +925 -0
- minion_code/screens/__init__.py +4 -0
- minion_code/services/__init__.py +50 -0
- minion_code/services/event_system.py +108 -0
- minion_code/services/file_freshness_service.py +582 -0
- minion_code/tools/__init__.py +69 -0
- minion_code/tools/bash_tool.py +58 -0
- minion_code/tools/file_edit_tool.py +238 -0
- minion_code/tools/file_read_tool.py +73 -0
- minion_code/tools/file_write_tool.py +36 -0
- minion_code/tools/glob_tool.py +58 -0
- minion_code/tools/grep_tool.py +105 -0
- minion_code/tools/ls_tool.py +65 -0
- minion_code/tools/multi_edit_tool.py +271 -0
- minion_code/tools/python_interpreter_tool.py +105 -0
- minion_code/tools/todo_read_tool.py +100 -0
- minion_code/tools/todo_write_tool.py +234 -0
- minion_code/tools/user_input_tool.py +53 -0
- minion_code/types.py +88 -0
- minion_code/utils/__init__.py +44 -0
- minion_code/utils/mcp_loader.py +211 -0
- minion_code/utils/todo_file_utils.py +110 -0
- minion_code/utils/todo_storage.py +149 -0
- minion_code-0.1.0.dist-info/METADATA +350 -0
- minion_code-0.1.0.dist-info/RECORD +59 -0
- minion_code-0.1.0.dist-info/WHEEL +5 -0
- minion_code-0.1.0.dist-info/entry_points.txt +4 -0
- minion_code-0.1.0.dist-info/licenses/LICENSE +661 -0
- minion_code-0.1.0.dist-info/top_level.txt +3 -0
- tests/__init__.py +1 -0
- tests/test_basic.py +20 -0
- tests/test_readonly_tools.py +102 -0
- tests/test_tools.py +83 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Bash command execution tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from minion.tools import BaseTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BashTool(BaseTool):
|
|
14
|
+
"""Bash command execution tool"""
|
|
15
|
+
|
|
16
|
+
name = "bash"
|
|
17
|
+
description = "Execute bash commands"
|
|
18
|
+
readonly = False # Command execution may modify system state
|
|
19
|
+
inputs = {
|
|
20
|
+
"command": {"type": "string", "description": "Bash command to execute"},
|
|
21
|
+
"timeout": {
|
|
22
|
+
"type": "integer",
|
|
23
|
+
"description": "Timeout in seconds",
|
|
24
|
+
"nullable": True,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
output_type = "string"
|
|
28
|
+
|
|
29
|
+
def forward(self, command: str, timeout: Optional[int] = 30) -> str:
|
|
30
|
+
"""Execute bash command"""
|
|
31
|
+
try:
|
|
32
|
+
# Security check: prohibit dangerous commands
|
|
33
|
+
dangerous_commands = ["rm -rf", "sudo", "su", "chmod 777", "mkfs", "dd if="]
|
|
34
|
+
if any(dangerous in command.lower() for dangerous in dangerous_commands):
|
|
35
|
+
return f"Error: Dangerous command prohibited - {command}"
|
|
36
|
+
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
command,
|
|
39
|
+
shell=True,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
cwd=os.getcwd(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
output = ""
|
|
47
|
+
if result.stdout:
|
|
48
|
+
output += f"Standard output:\n{result.stdout}\n"
|
|
49
|
+
if result.stderr:
|
|
50
|
+
output += f"Standard error:\n{result.stderr}\n"
|
|
51
|
+
output += f"Exit code: {result.returncode}"
|
|
52
|
+
|
|
53
|
+
return output
|
|
54
|
+
|
|
55
|
+
except subprocess.TimeoutExpired:
|
|
56
|
+
return f"Command execution timeout ({timeout} seconds)"
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return f"Error executing command: {str(e)}"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
File editing tool based on TypeScript FileEditTool implementation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
from minion.tools import BaseTool
|
|
11
|
+
from minion_code.services import record_file_read, record_file_edit, check_file_freshness
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileEditTool(BaseTool):
|
|
15
|
+
"""
|
|
16
|
+
A tool for editing files with string replacement.
|
|
17
|
+
Based on the TypeScript FileEditTool implementation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "file_edit"
|
|
21
|
+
description = "A tool for editing files by replacing old_string with new_string with freshness tracking"
|
|
22
|
+
readonly = False
|
|
23
|
+
|
|
24
|
+
inputs = {
|
|
25
|
+
"file_path": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "The absolute path to the file to modify"
|
|
28
|
+
},
|
|
29
|
+
"old_string": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "The text to replace (must be unique within the file)"
|
|
32
|
+
},
|
|
33
|
+
"new_string": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "The text to replace it with"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
output_type = "string"
|
|
39
|
+
|
|
40
|
+
def forward(self, file_path: str, old_string: str, new_string: str) -> str:
|
|
41
|
+
"""Execute file edit operation."""
|
|
42
|
+
try:
|
|
43
|
+
# Validate inputs
|
|
44
|
+
validation_result = self._validate_input(file_path, old_string, new_string)
|
|
45
|
+
if not validation_result["valid"]:
|
|
46
|
+
return f"Error: {validation_result['message']}"
|
|
47
|
+
|
|
48
|
+
# Apply the edit
|
|
49
|
+
result = self._apply_edit(file_path, old_string, new_string)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return f"Error during file edit: {str(e)}"
|
|
54
|
+
|
|
55
|
+
def _validate_input(self, file_path: str, old_string: str, new_string: str) -> Dict[str, Any]:
|
|
56
|
+
"""Validate input parameters."""
|
|
57
|
+
|
|
58
|
+
# Check if old_string and new_string are the same
|
|
59
|
+
if old_string == new_string:
|
|
60
|
+
return {
|
|
61
|
+
"valid": False,
|
|
62
|
+
"message": "No changes to make: old_string and new_string are exactly the same."
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Resolve absolute path
|
|
66
|
+
if not os.path.isabs(file_path):
|
|
67
|
+
file_path = os.path.abspath(file_path)
|
|
68
|
+
|
|
69
|
+
# Handle new file creation
|
|
70
|
+
if not os.path.exists(file_path) and old_string == "":
|
|
71
|
+
return {"valid": True}
|
|
72
|
+
|
|
73
|
+
# Check if file exists for existing file edits
|
|
74
|
+
if not os.path.exists(file_path):
|
|
75
|
+
return {
|
|
76
|
+
"valid": False,
|
|
77
|
+
"message": "File does not exist."
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Check if it's a Jupyter notebook
|
|
81
|
+
if file_path.endswith('.ipynb'):
|
|
82
|
+
return {
|
|
83
|
+
"valid": False,
|
|
84
|
+
"message": "File is a Jupyter Notebook. Use NotebookEdit tool instead."
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Check file freshness (if we have tracking)
|
|
88
|
+
try:
|
|
89
|
+
freshness_result = check_file_freshness(file_path)
|
|
90
|
+
if freshness_result.conflict:
|
|
91
|
+
return {
|
|
92
|
+
"valid": False,
|
|
93
|
+
"message": "File has been modified since last read. Read it again before editing."
|
|
94
|
+
}
|
|
95
|
+
except Exception:
|
|
96
|
+
# If freshness checking fails, continue with basic validation
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Check if file is binary
|
|
100
|
+
if self._is_binary_file(file_path):
|
|
101
|
+
return {
|
|
102
|
+
"valid": False,
|
|
103
|
+
"message": "Cannot edit binary files."
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# For existing files, validate old_string exists and is unique
|
|
107
|
+
if old_string != "":
|
|
108
|
+
try:
|
|
109
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
110
|
+
content = f.read()
|
|
111
|
+
|
|
112
|
+
if old_string not in content:
|
|
113
|
+
return {
|
|
114
|
+
"valid": False,
|
|
115
|
+
"message": "String to replace not found in file."
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Check for multiple matches
|
|
119
|
+
matches = content.count(old_string)
|
|
120
|
+
if matches > 1:
|
|
121
|
+
return {
|
|
122
|
+
"valid": False,
|
|
123
|
+
"message": f"Found {matches} matches of the string to replace. "
|
|
124
|
+
"For safety, this tool only supports replacing exactly one occurrence at a time. "
|
|
125
|
+
"Add more lines of context to your edit and try again."
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
except UnicodeDecodeError:
|
|
129
|
+
return {
|
|
130
|
+
"valid": False,
|
|
131
|
+
"message": "Cannot read file - appears to be binary or has encoding issues."
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {"valid": True}
|
|
135
|
+
|
|
136
|
+
def _apply_edit(self, file_path: str, old_string: str, new_string: str) -> str:
|
|
137
|
+
"""Apply the edit to the file."""
|
|
138
|
+
|
|
139
|
+
# Resolve absolute path
|
|
140
|
+
if not os.path.isabs(file_path):
|
|
141
|
+
file_path = os.path.abspath(file_path)
|
|
142
|
+
|
|
143
|
+
# Handle new file creation
|
|
144
|
+
if old_string == "":
|
|
145
|
+
# Create new file
|
|
146
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
147
|
+
|
|
148
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
149
|
+
f.write(new_string)
|
|
150
|
+
|
|
151
|
+
# Record the file edit
|
|
152
|
+
record_file_edit(file_path, new_string)
|
|
153
|
+
|
|
154
|
+
return f"Successfully created new file: {file_path}"
|
|
155
|
+
|
|
156
|
+
# Edit existing file
|
|
157
|
+
try:
|
|
158
|
+
# Read current content
|
|
159
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
160
|
+
original_content = f.read()
|
|
161
|
+
|
|
162
|
+
# Apply replacement
|
|
163
|
+
if new_string == "":
|
|
164
|
+
# Handle deletion - check if we need to remove trailing newline
|
|
165
|
+
if (not old_string.endswith('\n') and
|
|
166
|
+
original_content.find(old_string + '\n') != -1):
|
|
167
|
+
updated_content = original_content.replace(old_string + '\n', new_string)
|
|
168
|
+
else:
|
|
169
|
+
updated_content = original_content.replace(old_string, new_string)
|
|
170
|
+
else:
|
|
171
|
+
updated_content = original_content.replace(old_string, new_string)
|
|
172
|
+
|
|
173
|
+
# Verify the replacement worked
|
|
174
|
+
if updated_content == original_content:
|
|
175
|
+
return "Error: Original and edited file match exactly. Failed to apply edit."
|
|
176
|
+
|
|
177
|
+
# Write updated content
|
|
178
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
179
|
+
f.write(updated_content)
|
|
180
|
+
|
|
181
|
+
# Record the file edit
|
|
182
|
+
record_file_edit(file_path, updated_content)
|
|
183
|
+
|
|
184
|
+
# Generate result message with snippet
|
|
185
|
+
snippet_info = self._get_snippet(original_content, old_string, new_string)
|
|
186
|
+
|
|
187
|
+
result = f"The file {file_path} has been updated. Here's the result of the edit:\n"
|
|
188
|
+
result += self._add_line_numbers(snippet_info['snippet'], snippet_info['start_line'])
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
return f"Error applying edit: {str(e)}"
|
|
194
|
+
|
|
195
|
+
def _is_binary_file(self, file_path: str) -> bool:
|
|
196
|
+
"""Check if file is binary."""
|
|
197
|
+
try:
|
|
198
|
+
with open(file_path, 'rb') as f:
|
|
199
|
+
chunk = f.read(1024)
|
|
200
|
+
return b'\0' in chunk
|
|
201
|
+
except Exception:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def _get_snippet(self, original_content: str, old_string: str, new_string: str,
|
|
205
|
+
context_lines: int = 4) -> Dict[str, Any]:
|
|
206
|
+
"""Get a snippet of the file showing the change with context."""
|
|
207
|
+
|
|
208
|
+
# Find the replacement position
|
|
209
|
+
before_replacement = original_content.split(old_string)[0]
|
|
210
|
+
replacement_line = before_replacement.count('\n')
|
|
211
|
+
|
|
212
|
+
# Create the new content
|
|
213
|
+
new_content = original_content.replace(old_string, new_string)
|
|
214
|
+
new_lines = new_content.split('\n')
|
|
215
|
+
|
|
216
|
+
# Calculate snippet boundaries
|
|
217
|
+
start_line = max(0, replacement_line - context_lines)
|
|
218
|
+
end_line = min(len(new_lines), replacement_line + context_lines + new_string.count('\n') + 1)
|
|
219
|
+
|
|
220
|
+
# Extract snippet
|
|
221
|
+
snippet_lines = new_lines[start_line:end_line]
|
|
222
|
+
snippet = '\n'.join(snippet_lines)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
'snippet': snippet,
|
|
226
|
+
'start_line': start_line + 1 # Convert to 1-based line numbers
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
|
|
230
|
+
"""Add line numbers to content."""
|
|
231
|
+
lines = content.split('\n')
|
|
232
|
+
numbered_lines = []
|
|
233
|
+
|
|
234
|
+
for i, line in enumerate(lines):
|
|
235
|
+
line_num = start_line + i
|
|
236
|
+
numbered_lines.append(f"{line_num:6d} {line}")
|
|
237
|
+
|
|
238
|
+
return '\n'.join(numbered_lines)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
File reading tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from minion.tools import BaseTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileReadTool(BaseTool):
|
|
13
|
+
"""File reading tool"""
|
|
14
|
+
|
|
15
|
+
name = "file_read"
|
|
16
|
+
description = "Read file content, supports text files and image files"
|
|
17
|
+
readonly = True # Read-only tool, does not modify system state
|
|
18
|
+
inputs = {
|
|
19
|
+
"file_path": {"type": "string", "description": "File path to read"},
|
|
20
|
+
"offset": {
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"description": "Starting line number (optional)",
|
|
23
|
+
"nullable": True,
|
|
24
|
+
},
|
|
25
|
+
"limit": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"description": "Line count limit (optional)",
|
|
28
|
+
"nullable": True,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
output_type = "string"
|
|
32
|
+
|
|
33
|
+
def forward(
|
|
34
|
+
self, file_path: str, offset: Optional[int] = None, limit: Optional[int] = None
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Read file content"""
|
|
37
|
+
try:
|
|
38
|
+
path = Path(file_path)
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return f"Error: File does not exist - {file_path}"
|
|
41
|
+
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return f"Error: Path is not a file - {file_path}"
|
|
44
|
+
|
|
45
|
+
# Check if it's an image file
|
|
46
|
+
image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
|
47
|
+
if path.suffix.lower() in image_extensions:
|
|
48
|
+
return f"Image file: {file_path} (size: {path.stat().st_size} bytes)"
|
|
49
|
+
|
|
50
|
+
# Read text file
|
|
51
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
52
|
+
lines = f.readlines()
|
|
53
|
+
|
|
54
|
+
total_lines = len(lines)
|
|
55
|
+
|
|
56
|
+
# Apply offset and limit
|
|
57
|
+
if offset is not None:
|
|
58
|
+
lines = lines[offset:]
|
|
59
|
+
if limit is not None:
|
|
60
|
+
lines = lines[:limit]
|
|
61
|
+
|
|
62
|
+
content = "".join(lines)
|
|
63
|
+
|
|
64
|
+
result = f"File: {file_path}\n"
|
|
65
|
+
result += f"Total lines: {total_lines}\n"
|
|
66
|
+
if offset is not None or limit is not None:
|
|
67
|
+
result += f"Displayed lines: {len(lines)}\n"
|
|
68
|
+
result += f"Content:\n{content}"
|
|
69
|
+
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return f"Error reading file: {str(e)}"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
File writing tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from minion.tools import BaseTool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileWriteTool(BaseTool):
|
|
12
|
+
"""File writing tool"""
|
|
13
|
+
|
|
14
|
+
name = "file_write"
|
|
15
|
+
description = "Write content to file"
|
|
16
|
+
readonly = False # Writing tool, modifies system state
|
|
17
|
+
inputs = {
|
|
18
|
+
"file_path": {"type": "string", "description": "File path to write to"},
|
|
19
|
+
"content": {"type": "string", "description": "Content to write"},
|
|
20
|
+
}
|
|
21
|
+
output_type = "string"
|
|
22
|
+
|
|
23
|
+
def forward(self, file_path: str, content: str) -> str:
|
|
24
|
+
"""Write file content"""
|
|
25
|
+
try:
|
|
26
|
+
path = Path(file_path)
|
|
27
|
+
# Create directory if it doesn't exist
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
31
|
+
f.write(content)
|
|
32
|
+
|
|
33
|
+
return f"Successfully wrote to file: {file_path} ({len(content)} characters)"
|
|
34
|
+
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return f"Error writing file: {str(e)}"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
File pattern matching tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from minion.tools import BaseTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GlobTool(BaseTool):
|
|
13
|
+
"""File pattern matching tool"""
|
|
14
|
+
|
|
15
|
+
name = "glob"
|
|
16
|
+
description = "Match files using glob patterns"
|
|
17
|
+
readonly = True # Read-only tool, does not modify system state
|
|
18
|
+
inputs = {
|
|
19
|
+
"pattern": {"type": "string", "description": "Glob pattern"},
|
|
20
|
+
"path": {"type": "string", "description": "Search path", "nullable": True},
|
|
21
|
+
}
|
|
22
|
+
output_type = "string"
|
|
23
|
+
|
|
24
|
+
def forward(self, pattern: str, path: str = ".") -> str:
|
|
25
|
+
"""Match files using glob pattern"""
|
|
26
|
+
try:
|
|
27
|
+
search_path = Path(path)
|
|
28
|
+
if not search_path.exists():
|
|
29
|
+
return f"Error: Path does not exist - {path}"
|
|
30
|
+
|
|
31
|
+
# Build complete search pattern
|
|
32
|
+
if search_path.is_dir():
|
|
33
|
+
full_pattern = str(search_path / pattern)
|
|
34
|
+
else:
|
|
35
|
+
full_pattern = pattern
|
|
36
|
+
|
|
37
|
+
matches = glob.glob(full_pattern, recursive=True)
|
|
38
|
+
matches.sort()
|
|
39
|
+
|
|
40
|
+
if not matches:
|
|
41
|
+
return f"No files found matching pattern '{pattern}'"
|
|
42
|
+
|
|
43
|
+
result = f"Files matching pattern '{pattern}':\n"
|
|
44
|
+
for match in matches:
|
|
45
|
+
path_obj = Path(match)
|
|
46
|
+
if path_obj.is_file():
|
|
47
|
+
size = path_obj.stat().st_size
|
|
48
|
+
result += f" File: {match} ({size} bytes)\n"
|
|
49
|
+
elif path_obj.is_dir():
|
|
50
|
+
result += f" Directory: {match}/\n"
|
|
51
|
+
else:
|
|
52
|
+
result += f" Other: {match}\n"
|
|
53
|
+
|
|
54
|
+
result += f"\nTotal {len(matches)} matches found"
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return f"Error during glob matching: {str(e)}"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Text search tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from minion.tools import BaseTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GrepTool(BaseTool):
|
|
14
|
+
"""Text search tool"""
|
|
15
|
+
|
|
16
|
+
name = "grep"
|
|
17
|
+
description = "Search for text patterns in files"
|
|
18
|
+
readonly = True # Read-only tool, does not modify system state
|
|
19
|
+
inputs = {
|
|
20
|
+
"pattern": {"type": "string", "description": "Regular expression pattern to search for"},
|
|
21
|
+
"path": {"type": "string", "description": "Search path (file or directory)"},
|
|
22
|
+
"include": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "File pattern to include (optional)",
|
|
25
|
+
"nullable": True,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
output_type = "string"
|
|
29
|
+
|
|
30
|
+
def forward(
|
|
31
|
+
self, pattern: str, path: str = ".", include: Optional[str] = None
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Search for text pattern"""
|
|
34
|
+
try:
|
|
35
|
+
search_path = Path(path)
|
|
36
|
+
if not search_path.exists():
|
|
37
|
+
return f"Error: Path does not exist - {path}"
|
|
38
|
+
|
|
39
|
+
matches = []
|
|
40
|
+
|
|
41
|
+
if search_path.is_file():
|
|
42
|
+
# Search single file
|
|
43
|
+
matches.extend(self._search_file(search_path, pattern))
|
|
44
|
+
else:
|
|
45
|
+
# Search directory
|
|
46
|
+
if include:
|
|
47
|
+
# Filter using file pattern
|
|
48
|
+
for file_path in search_path.rglob(include):
|
|
49
|
+
if file_path.is_file():
|
|
50
|
+
matches.extend(self._search_file(file_path, pattern))
|
|
51
|
+
else:
|
|
52
|
+
# Search all text files
|
|
53
|
+
for file_path in search_path.rglob("*"):
|
|
54
|
+
if file_path.is_file() and self._is_text_file(file_path):
|
|
55
|
+
matches.extend(self._search_file(file_path, pattern))
|
|
56
|
+
|
|
57
|
+
if not matches:
|
|
58
|
+
return f"No content found matching pattern '{pattern}'"
|
|
59
|
+
|
|
60
|
+
# Group results by file
|
|
61
|
+
result = f"Search results for pattern '{pattern}':\n\n"
|
|
62
|
+
current_file = None
|
|
63
|
+
for file_path, line_num, line_content in matches:
|
|
64
|
+
if file_path != current_file:
|
|
65
|
+
result += f"File: {file_path}\n"
|
|
66
|
+
current_file = file_path
|
|
67
|
+
result += f" Line {line_num}: {line_content.strip()}\n"
|
|
68
|
+
|
|
69
|
+
result += f"\nTotal {len(matches)} matches found"
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return f"Error during search: {str(e)}"
|
|
74
|
+
|
|
75
|
+
def _search_file(self, file_path: Path, pattern: str) -> List[tuple]:
|
|
76
|
+
"""Search pattern in a single file"""
|
|
77
|
+
matches = []
|
|
78
|
+
try:
|
|
79
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
80
|
+
for line_num, line in enumerate(f, 1):
|
|
81
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
82
|
+
matches.append((str(file_path), line_num, line))
|
|
83
|
+
except Exception:
|
|
84
|
+
# Ignore files that cannot be read
|
|
85
|
+
pass
|
|
86
|
+
return matches
|
|
87
|
+
|
|
88
|
+
def _is_text_file(self, file_path: Path) -> bool:
|
|
89
|
+
"""Check if file is a text file"""
|
|
90
|
+
text_extensions = {
|
|
91
|
+
".txt",
|
|
92
|
+
".py",
|
|
93
|
+
".js",
|
|
94
|
+
".html",
|
|
95
|
+
".css",
|
|
96
|
+
".json",
|
|
97
|
+
".xml",
|
|
98
|
+
".md",
|
|
99
|
+
".yml",
|
|
100
|
+
".yaml",
|
|
101
|
+
".ini",
|
|
102
|
+
".cfg",
|
|
103
|
+
".conf",
|
|
104
|
+
}
|
|
105
|
+
return file_path.suffix.lower() in text_extensions
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Directory listing tool
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from minion.tools import BaseTool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LsTool(BaseTool):
|
|
12
|
+
"""Directory listing tool"""
|
|
13
|
+
|
|
14
|
+
name = "ls"
|
|
15
|
+
description = "List directory contents"
|
|
16
|
+
readonly = True # Read-only tool, does not modify system state
|
|
17
|
+
inputs = {
|
|
18
|
+
"path": {"type": "string", "description": "Directory path to list", "nullable": True},
|
|
19
|
+
"recursive": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"description": "Whether to list recursively",
|
|
22
|
+
"nullable": True,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
output_type = "string"
|
|
26
|
+
|
|
27
|
+
def forward(self, path: str = ".", recursive: bool = False) -> str:
|
|
28
|
+
"""List directory contents"""
|
|
29
|
+
try:
|
|
30
|
+
dir_path = Path(path)
|
|
31
|
+
if not dir_path.exists():
|
|
32
|
+
return f"Error: Path does not exist - {path}"
|
|
33
|
+
|
|
34
|
+
if not dir_path.is_dir():
|
|
35
|
+
return f"Error: Path is not a directory - {path}"
|
|
36
|
+
|
|
37
|
+
result = f"Directory contents: {path}\n\n"
|
|
38
|
+
|
|
39
|
+
if recursive:
|
|
40
|
+
# List recursively
|
|
41
|
+
for item in sorted(dir_path.rglob("*")):
|
|
42
|
+
relative_path = item.relative_to(dir_path)
|
|
43
|
+
if item.is_file():
|
|
44
|
+
size = item.stat().st_size
|
|
45
|
+
result += f" File: {relative_path} ({size} bytes)\n"
|
|
46
|
+
elif item.is_dir():
|
|
47
|
+
result += f" Directory: {relative_path}/\n"
|
|
48
|
+
else:
|
|
49
|
+
# List current directory only
|
|
50
|
+
items = list(dir_path.iterdir())
|
|
51
|
+
items.sort(key=lambda x: (x.is_file(), x.name.lower()))
|
|
52
|
+
|
|
53
|
+
for item in items:
|
|
54
|
+
if item.is_file():
|
|
55
|
+
size = item.stat().st_size
|
|
56
|
+
result += f" File: {item.name} ({size} bytes)\n"
|
|
57
|
+
elif item.is_dir():
|
|
58
|
+
result += f" Directory: {item.name}/\n"
|
|
59
|
+
else:
|
|
60
|
+
result += f" Other: {item.name}\n"
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return f"Error listing directory: {str(e)}"
|