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/__init__.py +1 -0
- grucli/api.py +725 -0
- grucli/auth.py +115 -0
- grucli/chat_manager.py +190 -0
- grucli/commands.py +318 -0
- grucli/config.py +262 -0
- grucli/handlers.py +75 -0
- grucli/interrupt.py +179 -0
- grucli/main.py +617 -0
- grucli/permissions.py +181 -0
- grucli/stats.py +100 -0
- grucli/sysprompts/main_sysprompt.txt +65 -0
- grucli/theme.py +144 -0
- grucli/tools.py +368 -0
- grucli/ui.py +496 -0
- grucli-3.3.0.dist-info/METADATA +145 -0
- grucli-3.3.0.dist-info/RECORD +21 -0
- grucli-3.3.0.dist-info/WHEEL +5 -0
- grucli-3.3.0.dist-info/entry_points.txt +2 -0
- grucli-3.3.0.dist-info/licenses/LICENSE +21 -0
- grucli-3.3.0.dist-info/top_level.txt +1 -0
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
|