grucli 3.3.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.
grucli/tools.py ADDED
@@ -0,0 +1,368 @@
1
+ import os
2
+ import ast
3
+ import shutil
4
+ import re
5
+ from .theme import Colors
6
+
7
+ # bot should not escape current working dir
8
+ WORKING_DIR = os.getcwd()
9
+
10
+ # Constant error messages for tools to avoid copy-paste
11
+ ERROR_PATH_OUTSIDE_DIR = "Error: Please stay within current directory, your file paths should be relative, not absolute."
12
+ ERROR_FILE_DOES_NOT_EXIST = "Error: File {path} does not exist, refer to file tree, file paths should be relative, not absolute"
13
+ ERROR_READING_FILE = "Error reading file: {error}"
14
+ ERROR_EDITING_FILE_NO_MATCH = "Error: old_string not found in file. Exact match required, use read file tool to analyse."
15
+ ERROR_EDITING_FILE_MULTIPLE_MATCHES = "Error: old_string found multiple times in file ({count} occurrences). Please provide a more unique string or include surrounding context."
16
+ ERROR_EDITING_FILE_IDENTICAL = "Error: old_string and new_string are identical. No changes made."
17
+ ERROR_EDITING_FILE = "Error editing file: {error}"
18
+ ERROR_DELETING_FILE = "Error deleting file: {error}"
19
+
20
+ def is_safe_path(path):
21
+ # resolves symlinks and relative paths for it to stay within current dir
22
+ if path is None:
23
+ return False, None
24
+
25
+ # Force path to be relative by stripping leading slashes
26
+ # LLMs often try /main.py when they mean main.py
27
+ path = path.lstrip('/')
28
+
29
+ # If it's absolute, check if it's within WORKING_DIR directly
30
+ if os.path.isabs(path):
31
+ target = os.path.abspath(path)
32
+ else:
33
+ target = os.path.abspath(os.path.join(WORKING_DIR, path))
34
+
35
+ try:
36
+ is_safe = os.path.commonpath([WORKING_DIR, target]) == WORKING_DIR
37
+ except ValueError:
38
+ return False, None
39
+
40
+ return is_safe, target
41
+
42
+ def get_file_tree(startpath='.'):
43
+ # generate a cool looking tree view for the context
44
+ tree_str = ""
45
+ for root, dirs, files in os.walk(startpath):
46
+ # skip hidden stuff
47
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
48
+ level = root.replace(startpath, '').count(os.sep)
49
+ indent = ' ' * 4 * (level)
50
+ tree_str += f"{indent}{os.path.basename(root)}/\n"
51
+ subindent = ' ' * 4 * (level + 1)
52
+ for f in files:
53
+ if not f.startswith('.'):
54
+ tree_str += f"{subindent}{f}\n"
55
+
56
+ if not tree_str.strip():
57
+ return "Current directory empty"
58
+
59
+ return tree_str
60
+
61
+ def read_file(path, start_line=None, end_line=None):
62
+ path = path.lstrip('/')
63
+ safe, full_path = is_safe_path(path)
64
+ if not safe:
65
+ return ERROR_PATH_OUTSIDE_DIR
66
+
67
+ if not os.path.exists(full_path):
68
+ return ERROR_FILE_DOES_NOT_EXIST.format(path=path)
69
+
70
+ try:
71
+ with open(full_path, 'r', encoding='utf-8') as f:
72
+ # if no range specified, return everything efficiently
73
+ if start_line is None and end_line is None:
74
+ return f.read()
75
+
76
+ lines = f.readlines()
77
+
78
+ # fix for potential NoneType math error
79
+ s = (int(start_line) - 1) if start_line is not None else 0
80
+ e = int(end_line) if end_line is not None else len(lines)
81
+
82
+ # clamp values
83
+ s = max(0, s)
84
+ e = min(len(lines), e)
85
+
86
+ return "".join(lines[s:e])
87
+ except Exception as e:
88
+ return ERROR_READING_FILE.format(error=str(e))
89
+
90
+ def create_file(path, content):
91
+ path = path.lstrip('/')
92
+ safe, full_path = is_safe_path(path)
93
+ if not safe:
94
+ return ERROR_PATH_OUTSIDE_DIR
95
+
96
+ try:
97
+ # ensure subdirs exist
98
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
99
+ with open(full_path, 'w', encoding='utf-8') as f:
100
+ f.write(content)
101
+ return f"success: created {path}"
102
+ except Exception as e:
103
+ return f"Error creating file: {e}"
104
+
105
+ def edit_file(path, old_string, new_string):
106
+ if new_string is None:
107
+ new_string = ""
108
+
109
+ path = path.lstrip('/')
110
+ safe, full_path = is_safe_path(path)
111
+ if not safe:
112
+ return ERROR_PATH_OUTSIDE_DIR
113
+
114
+ if not os.path.exists(full_path):
115
+ return ERROR_FILE_DOES_NOT_EXIST.format(path=path)
116
+
117
+ try:
118
+ if old_string == new_string:
119
+ return ERROR_EDITING_FILE_IDENTICAL
120
+
121
+ with open(full_path, 'r', encoding='utf-8') as f:
122
+ content = f.read()
123
+
124
+ count = content.count(old_string)
125
+ if count == 0:
126
+ return ERROR_EDITING_FILE_NO_MATCH
127
+ if count > 1:
128
+ return ERROR_EDITING_FILE_MULTIPLE_MATCHES.format(count=count)
129
+
130
+ new_content = content.replace(old_string, new_string)
131
+ with open(full_path, 'w', encoding='utf-8') as f:
132
+ f.write(new_content)
133
+
134
+ return f"success: updated {path}"
135
+ except Exception as e:
136
+ return ERROR_EDITING_FILE.format(error=str(e))
137
+
138
+ def delete_file(path):
139
+ path = path.lstrip('/')
140
+ safe, full_path = is_safe_path(path)
141
+ if not safe:
142
+ return ERROR_PATH_OUTSIDE_DIR
143
+
144
+ if not os.path.exists(full_path):
145
+ return ERROR_FILE_DOES_NOT_EXIST.format(path=path)
146
+
147
+ try:
148
+ os.remove(full_path)
149
+ return f"success: deleted {path}"
150
+ except Exception as e:
151
+ return ERROR_DELETING_FILE.format(error=str(e))
152
+
153
+
154
+ def get_current_directory_structure():
155
+ return get_file_tree()
156
+
157
+ def list_files_recursive():
158
+ file_list = []
159
+ for root, dirs, files in os.walk(WORKING_DIR):
160
+ # skip hidden stuff
161
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
162
+ for f in files:
163
+ if not f.startswith('.'):
164
+ rel_path = os.path.relpath(os.path.join(root, f), WORKING_DIR)
165
+ file_list.append(rel_path)
166
+ return file_list
167
+
168
+ def normalize_call_syntax(call_str):
169
+ """
170
+ Normalizes LLM-generated tool calls to be valid Python syntax.
171
+ Specifically handles multi-line strings using single/double quotes by
172
+ converting them to triple-quoted strings.
173
+ """
174
+ def fix_quotes(m):
175
+ quote_type = m.group(1)
176
+ content = m.group(2)
177
+ # If it's already triple-quoted, leave it alone
178
+ if len(quote_type) == 3:
179
+ return m.group(0)
180
+ # If it's a single-line string, leave it alone
181
+ if '\n' not in content:
182
+ return m.group(0)
183
+ # Upgrade multi-line single/double quoted string to triple-quoted
184
+ return f"{quote_type*3}{content}{quote_type*3}"
185
+
186
+ # Matches triple quotes or single/double quotes, correctly handling escaped quotes
187
+ # group(1) is the quote delimiter, group(2) is the string content
188
+ pattern = r'(\'\'\'|"""|\'|")(.*?(?<!\\)(?:\\\\)*)\1'
189
+ return re.sub(pattern, fix_quotes, call_str, flags=re.DOTALL)
190
+
191
+
192
+ def repair_truncated_call(call_str):
193
+ """
194
+ Attempts to repair a truncated tool call by closing any open strings
195
+ and parentheses.
196
+ """
197
+ depth = 0
198
+ in_str = None
199
+ escape = False
200
+
201
+ i = 0
202
+ while i < len(call_str):
203
+ c = call_str[i]
204
+ if escape:
205
+ escape = False
206
+ i += 1
207
+ continue
208
+ if c == '\\':
209
+ escape = True
210
+ i += 1
211
+ continue
212
+
213
+ if in_str is None:
214
+ if call_str[i:i+3] in ("'''", '"""'):
215
+ in_str = call_str[i:i+3]
216
+ i += 3
217
+ continue
218
+ elif c in ("'", '"'):
219
+ in_str = c
220
+ i += 1
221
+ continue
222
+ elif c == "(":
223
+ depth += 1
224
+ elif c == ")":
225
+ depth -= 1
226
+ elif in_str in ("'''", '"""'):
227
+ if call_str[i:i+3] == in_str:
228
+ in_str = None
229
+ i += 3
230
+ continue
231
+ elif in_str in ("'", '"'):
232
+ if c == in_str:
233
+ in_str = None
234
+ i += 1
235
+
236
+ repaired = call_str
237
+ if in_str:
238
+ repaired += in_str
239
+
240
+ while depth > 0:
241
+ repaired += ")"
242
+ depth -= 1
243
+
244
+ return repaired
245
+
246
+
247
+ def parse_commands(text):
248
+ valid_tools = {
249
+ 'read_file': ['path', 'start_line', 'end_line'],
250
+ 'create_file': ['path', 'content'],
251
+ 'edit_file': ['path', 'old_string', 'new_string'],
252
+ 'delete_file': ['path'],
253
+ 'get_current_directory_structure': []
254
+ }
255
+
256
+ # Extract code blocks or use full text if no blocks but looks like tool call
257
+ blocks = re.findall(r'```(?:[a-zA-Z0-9]*)\n(.*?)\n```', text, re.DOTALL)
258
+ if not blocks:
259
+ if any(re.search(rf"\b{tool}\s*\(", text) for tool in valid_tools):
260
+ blocks = [text]
261
+ else:
262
+ return []
263
+
264
+ unique_commands = []
265
+ seen_originals = set()
266
+
267
+ for block in blocks:
268
+ for tool in valid_tools:
269
+ # Find every instance of "tool_name("
270
+ for match in re.finditer(rf"\b{tool}\s*\(", block):
271
+ start_idx = match.start()
272
+ # Find the matching closing parenthesis
273
+ depth = 0
274
+ in_str = None
275
+ escape = False
276
+ end_idx = -1
277
+
278
+ i = start_idx
279
+ while i < len(block):
280
+ c = block[i]
281
+
282
+ if escape:
283
+ escape = False
284
+ i += 1
285
+ continue
286
+ if c == '\\':
287
+ escape = True
288
+ i += 1
289
+ continue
290
+
291
+ if in_str is None:
292
+ if block[i:i+3] in ("'''", '"""'):
293
+ in_str = block[i:i+3]
294
+ i += 3
295
+ continue
296
+ elif c in ("'", '"'):
297
+ in_str = c
298
+ i += 1
299
+ continue
300
+ elif c == "(":
301
+ depth += 1
302
+ elif c == ")":
303
+ depth -= 1
304
+ if depth == 0:
305
+ end_idx = i + 1
306
+ break
307
+ elif in_str in ("'''", '"""'):
308
+ if block[i:i+3] == in_str:
309
+ in_str = None
310
+ i += 3
311
+ continue
312
+ elif in_str in ("'", '"'):
313
+ if c == in_str:
314
+ in_str = None
315
+ i += 1
316
+
317
+ if end_idx != -1:
318
+ call_str = block[start_idx:end_idx]
319
+ else:
320
+ # Truncated call? Let's try to repair it if it's at the end of the block
321
+ call_str = repair_truncated_call(block[start_idx:])
322
+
323
+ try:
324
+ call_str_normalized = re.sub(rf"^{tool}\s*\(", f"{tool}(", call_str, count=1)
325
+ # Fix LLM multi-line string mistakes
326
+ call_str_normalized = normalize_call_syntax(call_str_normalized)
327
+
328
+ # Use ast to safely extract arguments from the isolated call string
329
+ tree = ast.parse(call_str_normalized)
330
+ if isinstance(tree.body[0], ast.Expr) and isinstance(tree.body[0].value, ast.Call):
331
+ node = tree.body[0].value
332
+ args = {}
333
+ arg_names = valid_tools[tool]
334
+
335
+ for j, arg in enumerate(node.args):
336
+ if j < len(arg_names):
337
+ args[arg_names[j]] = ast.literal_eval(arg)
338
+
339
+ for keyword in node.keywords:
340
+ args[keyword.arg] = ast.literal_eval(keyword.value)
341
+
342
+ # Deduplicate based on name and arguments to handle minor syntax differences
343
+ arg_items = tuple(sorted((k, str(v)) for k, v in args.items()))
344
+ call_key = (tool, arg_items)
345
+
346
+ if call_key not in seen_originals:
347
+ unique_commands.append({
348
+ "name": tool,
349
+ "args": args,
350
+ "original": call_str
351
+ })
352
+ seen_originals.add(call_key)
353
+ except Exception as e:
354
+ error_msg = f"invalid syntax: {str(e)}"
355
+ error_key = (tool, error_msg, call_str)
356
+ if error_key not in seen_originals:
357
+ unique_commands.append({
358
+ "name": tool,
359
+ "error": error_msg,
360
+ "original": call_str
361
+ })
362
+ seen_originals.add(error_key)
363
+
364
+ if end_idx != -1:
365
+ print(f"{Colors.ERROR}Error parsing tool call: {e}{Colors.RESET}")
366
+ print(f"{Colors.MUTED}Failed call string: {call_str}{Colors.RESET}")
367
+
368
+ return unique_commands