llms-py 3.0.15__py3-none-any.whl → 3.0.17__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.
Files changed (29) hide show
  1. llms/extensions/app/__init__.py +0 -1
  2. llms/extensions/app/db.py +5 -1
  3. llms/extensions/computer/__init__.py +59 -0
  4. llms/extensions/{computer_use → computer}/bash.py +2 -2
  5. llms/extensions/{computer_use → computer}/edit.py +10 -14
  6. llms/extensions/computer/filesystem.py +542 -0
  7. llms/extensions/core_tools/__init__.py +0 -38
  8. llms/extensions/providers/cerebras.py +0 -1
  9. llms/extensions/providers/google.py +57 -30
  10. llms/extensions/skills/ui/index.mjs +27 -0
  11. llms/extensions/tools/__init__.py +5 -82
  12. llms/extensions/tools/ui/index.mjs +92 -4
  13. llms/main.py +225 -34
  14. llms/ui/ai.mjs +1 -1
  15. llms/ui/app.css +491 -0
  16. llms/ui/modules/chat/ChatBody.mjs +64 -9
  17. llms/ui/modules/chat/index.mjs +103 -91
  18. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/METADATA +1 -1
  19. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/RECORD +28 -27
  20. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/WHEEL +1 -1
  21. llms/extensions/computer_use/__init__.py +0 -27
  22. /llms/extensions/{computer_use → computer}/README.md +0 -0
  23. /llms/extensions/{computer_use → computer}/base.py +0 -0
  24. /llms/extensions/{computer_use → computer}/computer.py +0 -0
  25. /llms/extensions/{computer_use → computer}/platform.py +0 -0
  26. /llms/extensions/{computer_use → computer}/run.py +0 -0
  27. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/entry_points.txt +0 -0
  28. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/licenses/LICENSE +0 -0
  29. {llms_py-3.0.15.dist-info → llms_py-3.0.17.dist-info}/top_level.txt +0 -0
@@ -199,7 +199,6 @@ def install(ctx):
199
199
  "model": thread.get("model"),
200
200
  "messages": thread.get("messages"),
201
201
  "modalities": thread.get("modalities"),
202
- "systemPrompt": thread.get("systemPrompt"),
203
202
  "tools": thread.get("tools"), # tools request
204
203
  "metadata": metadata,
205
204
  }
llms/extensions/app/db.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import time
3
4
  from datetime import datetime, timedelta
4
5
  from typing import Any, Dict
5
6
 
@@ -344,9 +345,12 @@ class AppDB:
344
345
  else:
345
346
  thread["createdAt"] = now
346
347
  thread["updatedAt"] = now
348
+ initial_timestamp = int(time.time() * 1000) + 1
347
349
  if "messages" in thread:
348
- for m in thread["messages"]:
350
+ for idx, m in enumerate(thread["messages"]):
349
351
  self.ctx.cache_message_inline_data(m)
352
+ if "timestamp" not in m:
353
+ m["timestamp"] = initial_timestamp + idx
350
354
  return with_user(thread, user=user)
351
355
 
352
356
  def create_thread(self, thread: Dict[str, Any], user=None):
@@ -0,0 +1,59 @@
1
+ """
2
+ Anthropic's Computer Use Tools
3
+ https://github.com/anthropics/claude-quickstarts/tree/main/computer-use-demo
4
+ """
5
+
6
+ import os
7
+
8
+ from .bash import open, run_bash
9
+ from .computer import computer
10
+ from .edit import edit
11
+ from .filesystem import (
12
+ create_directory,
13
+ directory_tree,
14
+ edit_file,
15
+ filesystem_init,
16
+ get_file_info,
17
+ list_allowed_directories,
18
+ list_directory,
19
+ list_directory_with_sizes,
20
+ move_file,
21
+ read_media_file,
22
+ read_multiple_files,
23
+ read_text_file,
24
+ search_files,
25
+ write_file,
26
+ )
27
+ from .platform import get_display_num, get_screen_resolution
28
+
29
+ width, height = get_screen_resolution()
30
+ # set enviroment variables
31
+ os.environ["WIDTH"] = str(width)
32
+ os.environ["HEIGHT"] = str(height)
33
+ os.environ["DISPLAY_NUM"] = str(get_display_num())
34
+
35
+
36
+ def install(ctx):
37
+ filesystem_init(ctx)
38
+
39
+ ctx.register_tool(run_bash, group="computer")
40
+ ctx.register_tool(open, group="computer")
41
+ ctx.register_tool(edit, group="computer")
42
+ ctx.register_tool(computer, group="computer")
43
+
44
+ ctx.register_tool(read_text_file, group="filesystem")
45
+ ctx.register_tool(read_media_file, group="filesystem")
46
+ ctx.register_tool(read_multiple_files, group="filesystem")
47
+ ctx.register_tool(write_file, group="filesystem")
48
+ ctx.register_tool(edit_file, group="filesystem")
49
+ ctx.register_tool(create_directory, group="filesystem")
50
+ ctx.register_tool(list_directory, group="filesystem")
51
+ ctx.register_tool(list_directory_with_sizes, group="filesystem")
52
+ ctx.register_tool(directory_tree, group="filesystem")
53
+ ctx.register_tool(move_file, group="filesystem")
54
+ ctx.register_tool(search_files, group="filesystem")
55
+ ctx.register_tool(get_file_info, group="filesystem")
56
+ ctx.register_tool(list_allowed_directories, group="filesystem")
57
+
58
+
59
+ __install__ = install
@@ -158,7 +158,7 @@ async def run_bash(
158
158
  if g_tool is None:
159
159
  g_tool = BashTool20241022()
160
160
 
161
- result = await g_tool(command, restart)
161
+ result = await g_tool(command=command, restart=restart)
162
162
  if isinstance(result, Exception):
163
163
  raise result
164
164
  else:
@@ -182,4 +182,4 @@ async def open(target: Annotated[str, "URL or file path to open"]) -> list[dict[
182
182
  else: # Linux and other Unix-like
183
183
  cmd = ["xdg-open", target]
184
184
 
185
- return await run_bash(" ".join(cmd))
185
+ return await run_bash(command=" ".join(cmd))
@@ -1,6 +1,6 @@
1
1
  from collections import defaultdict
2
2
  from pathlib import Path
3
- from typing import Annotated, Any, Literal, get_args
3
+ from typing import Annotated, Any, List, Literal, get_args
4
4
 
5
5
  from .base import BaseTool, CLIResult, ToolError, ToolResult
6
6
  from .run import maybe_truncate, run
@@ -272,10 +272,10 @@ async def edit(
272
272
  command: Command_20250124,
273
273
  path: Annotated[str, "The absolute path to the file or directory"],
274
274
  file_text: Annotated[str | None, "The content to write to the file (required for create)"] = None,
275
- view_range: Annotated[list[int] | None, "The range of lines to view (e.g. [1, 10])"] = None,
275
+ view_range: Annotated[List[int], "The range of lines to view (e.g. [1, 10])"] = None,
276
276
  old_str: Annotated[str | None, "The string to replace (required for str_replace)"] = None,
277
277
  new_str: Annotated[str | None, "The replacement string (required for str_replace and insert)"] = None,
278
- insert_line: Annotated[int | None, "The line number after which to insert (required for insert)"] = None,
278
+ insert_line: Annotated[int, "The line number after which to insert (required for insert)"] = None,
279
279
  ) -> list[dict[str, Any]]:
280
280
  """
281
281
  An filesystem editor tool that allows the agent to view, create, and edit files.
@@ -284,18 +284,14 @@ async def edit(
284
284
  if g_tool is None:
285
285
  g_tool = EditTool20250124()
286
286
 
287
- view_range_values = None
288
- if view_range:
289
- view_range_values = [int(x) for x in view_range]
290
-
291
287
  result = await g_tool(
292
- command,
293
- path if path else None,
294
- file_text if file_text else None,
295
- view_range_values,
296
- old_str if old_str else None,
297
- new_str if new_str else None,
298
- int(insert_line) if insert_line else None,
288
+ command=command,
289
+ path=path if path else None,
290
+ file_text=file_text if file_text else None,
291
+ view_range=view_range,
292
+ old_str=old_str if old_str else None,
293
+ new_str=new_str if new_str else None,
294
+ insert_line=int(insert_line) if insert_line else None,
299
295
  )
300
296
  if isinstance(result, Exception):
301
297
  raise result
@@ -0,0 +1,542 @@
1
+ """
2
+ Anthropic's Filesystem MCP Tools
3
+ https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem
4
+
5
+ filesystem
6
+ {
7
+ "command": "npx",
8
+ "args": [
9
+ "-y",
10
+ "@modelcontextprotocol/server-filesystem",
11
+ "$PWD",
12
+ "$LLMS_HOME/.agent"
13
+ ]
14
+ }
15
+ """
16
+
17
+ import base64
18
+ import difflib
19
+ import fnmatch
20
+ import logging
21
+ import mimetypes
22
+ import os
23
+ import shutil
24
+ import time
25
+ from typing import Annotated, Any, Dict, List, Literal, Optional
26
+
27
+ # Configure logging
28
+ logger = logging.getLogger(__name__)
29
+
30
+ g_ctx = None
31
+
32
+
33
+ def filesystem_init(ctx):
34
+ global g_ctx
35
+ g_ctx = ctx
36
+
37
+
38
+ def get_app():
39
+ if g_ctx is None:
40
+ raise RuntimeError("Filesystem extension not initialized")
41
+ return g_ctx
42
+
43
+
44
+ def set_allowed_directories(directories: List[str]) -> None:
45
+ """Set the list of allowed directories.
46
+
47
+ Args:
48
+ directories: List of absolute paths that are allowed to be accessed.
49
+ """
50
+ get_app().set_allowed_directories(directories)
51
+
52
+
53
+ def add_allowed_directory(path: str) -> None:
54
+ """Add a directory to the allowed list.
55
+
56
+ Args:
57
+ path: Absolute path to add.
58
+ """
59
+ get_app().add_allowed_directory(path)
60
+
61
+
62
+ def get_allowed_directories() -> List[str]:
63
+ """
64
+ Returns the list of directories that this server is allowed to access.
65
+ """
66
+ return get_app().get_allowed_directories()
67
+
68
+
69
+ def list_allowed_directories() -> str:
70
+ """
71
+ Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible.
72
+ Use this to understand which directories and their nested paths are available before trying to access files.
73
+ """
74
+ return "Allowed directories:\n" + "\n".join(get_app().get_allowed_directories())
75
+
76
+
77
+ def _validate_path(path_str: str) -> str:
78
+ """Validate that the path is within one of the allowed directories.
79
+
80
+ Args:
81
+ path_str: The path to validate.
82
+
83
+ Returns:
84
+ The absolute validated path.
85
+
86
+ Raises:
87
+ ValueError: If path is invalid or not allowed.
88
+ """
89
+ if not path_str:
90
+ raise ValueError("Path cannot be empty")
91
+
92
+ # Expand user (~)
93
+ path_str = os.path.expanduser(path_str)
94
+
95
+ # Get absolute path
96
+ try:
97
+ abs_path = os.path.abspath(path_str)
98
+ except Exception as e:
99
+ raise ValueError(f"Invalid path: {e}") from e
100
+
101
+ # Check if path is within any allowed directory
102
+ is_allowed = False
103
+ for allowed_dir in get_allowed_directories():
104
+ # Check if abs_path starts with allowed_dir
105
+ # We add os.sep to ensure we don't match /app2 when allowed is /app
106
+ allowed_dir_str = str(allowed_dir)
107
+ if not allowed_dir_str.endswith(os.sep):
108
+ allowed_dir_str += os.sep
109
+
110
+ if abs_path.startswith(allowed_dir_str) or abs_path == allowed_dir:
111
+ is_allowed = True
112
+ break
113
+
114
+ if not is_allowed:
115
+ raise ValueError(
116
+ f"Access denied: {abs_path} is not within allowed directories:\n{', '.join(get_allowed_directories())}"
117
+ )
118
+
119
+ return abs_path
120
+
121
+
122
+ def _format_size(size_bytes: int) -> str:
123
+ """Format size in bytes to human readable string."""
124
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
125
+ if size_bytes < 1024.0:
126
+ return f"{size_bytes:.1f} {unit}"
127
+ size_bytes /= 1024.0
128
+ return f"{size_bytes:.1f} PB"
129
+
130
+
131
+ def _is_binary(path_str: str) -> bool:
132
+ """Check if file is binary (rudimentary check)."""
133
+ try:
134
+ with open(path_str) as check_file:
135
+ check_file.read(1024)
136
+ return False
137
+ except Exception:
138
+ return True
139
+
140
+
141
+ def read_text_file(
142
+ path: Annotated[str, "Path to the file."],
143
+ head: Annotated[Optional[int], "If provided, returns only the first N lines of the file"] = None,
144
+ tail: Annotated[Optional[int], "If provided, returns only the last N lines of the file"] = None,
145
+ ) -> str:
146
+ """
147
+ Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read.
148
+ Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file.
149
+ Operates on the file as text regardless of extension. Only works within allowed directories.
150
+ Returns: The content of the file.
151
+ """
152
+ valid_path = _validate_path(path)
153
+
154
+ if head is not None and tail is not None:
155
+ raise ValueError("Cannot specify both head and tail parameters simultaneously")
156
+
157
+ if not os.path.exists(valid_path):
158
+ raise FileNotFoundError(f"File not found: {valid_path}")
159
+
160
+ if not os.path.isfile(valid_path):
161
+ raise ValueError(f"Path is not a file: {valid_path}")
162
+
163
+ try:
164
+ with open(valid_path, encoding="utf-8", errors="replace") as f:
165
+ if head is not None:
166
+ lines = []
167
+ for _ in range(head):
168
+ line = f.readline()
169
+ if not line:
170
+ break
171
+ lines.append(line)
172
+ return "".join(lines)
173
+
174
+ if tail is not None:
175
+ # This could be optimized for large files but reading all lines is safer for simple impl
176
+ lines = f.readlines()
177
+ return "".join(lines[-tail:])
178
+
179
+ return f.read()
180
+ except Exception as e:
181
+ raise RuntimeError(f"Error reading file {valid_path}: {e}") from e
182
+
183
+
184
+ def read_media_file(path: Annotated[str, "Path to the file"]) -> Dict[str, Any]:
185
+ """
186
+ Read an image or audio file.
187
+ Returns the base64 encoded data and MIME type. Only works within allowed directories.
188
+ """
189
+ valid_path = _validate_path(path)
190
+
191
+ if not os.path.exists(valid_path):
192
+ raise FileNotFoundError(f"File not found: {valid_path}")
193
+
194
+ mime_type, _ = mimetypes.guess_type(valid_path)
195
+ if not mime_type:
196
+ mime_type = "application/octet-stream"
197
+
198
+ try:
199
+ with open(valid_path, "rb") as f:
200
+ data = f.read()
201
+ b64_data = base64.b64encode(data).decode("utf-8")
202
+
203
+ file_type = "blob"
204
+ if mime_type.startswith("image/"):
205
+ file_type = "image"
206
+ elif mime_type.startswith("audio/"):
207
+ file_type = "audio"
208
+
209
+ return {"type": file_type, "data": b64_data, "mimeType": mime_type}
210
+ except Exception as e:
211
+ raise RuntimeError(f"Error reading media file {valid_path}: {e}") from e
212
+
213
+
214
+ def read_multiple_files(paths: Annotated[List[str], "List of file paths to read"]) -> str:
215
+ """
216
+ Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files.
217
+ Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.
218
+ Returns: Concatenated contents with file separators.
219
+ """
220
+ results = []
221
+ for p in paths:
222
+ try:
223
+ content = read_text_file(p)
224
+ results.append(f"{p}:\n{content}\n")
225
+ except Exception as e:
226
+ results.append(f"{p}: Error - {e}")
227
+
228
+ return "\n---\n".join(results)
229
+
230
+
231
+ def write_file(path: Annotated[str, "Path to the file"], content: Annotated[str, "Content to write"]) -> str:
232
+ """
233
+ Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.
234
+ Returns: Success message.
235
+ """
236
+ valid_path = _validate_path(path)
237
+
238
+ try:
239
+ # Ensure parent directory exists
240
+ os.makedirs(os.path.dirname(valid_path), exist_ok=True)
241
+
242
+ with open(valid_path, "w", encoding="utf-8") as f:
243
+ f.write(content)
244
+
245
+ return f"Successfully wrote to {path}"
246
+ except Exception as e:
247
+ raise RuntimeError(f"Error writing to file {valid_path}: {e}") from e
248
+
249
+
250
+ def edit_file(
251
+ path: Annotated[str, "Path to the file"],
252
+ edits: Annotated[List[Dict[str, str]], "List of dicts with 'oldText' and 'newText'"],
253
+ dry_run: bool = False,
254
+ ) -> str:
255
+ """
256
+ Make line-based edits to a text file. Each edit replaces exact line sequences with new content.
257
+ Returns a git-style diff showing the changes made. Only works within allowed directories.
258
+ """
259
+
260
+ # Example edits: [{"oldText":"boy","newText":"girl"}]
261
+
262
+ valid_path = _validate_path(path)
263
+
264
+ if not os.path.exists(valid_path):
265
+ raise FileNotFoundError(f"File not found: {valid_path}")
266
+
267
+ with open(valid_path, encoding="utf-8") as f:
268
+ original_content = f.read()
269
+
270
+ current_content = original_content
271
+
272
+ # Apply edits sequentially
273
+ for edit in edits:
274
+ old_text = edit.get("oldText", "")
275
+ new_text = edit.get("newText", "")
276
+
277
+ if old_text not in current_content:
278
+ raise ValueError(f"Could not find exact match for text to replace: {old_text[:50]}...")
279
+
280
+ # Replace only the first occurrence to be safe?
281
+ # The Node impl likely replaces one instance or all?
282
+ # Usually exact match replacement implies replacing the instance found.
283
+ # Python's replace replaces all by default, so we limit to 1.
284
+ current_content = current_content.replace(old_text, new_text, 1)
285
+
286
+ # Generate diff
287
+ original_lines = original_content.splitlines(keepends=True)
288
+ new_lines = current_content.splitlines(keepends=True)
289
+
290
+ diff = list(difflib.unified_diff(original_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""))
291
+
292
+ diff_text = "".join(diff)
293
+
294
+ if not dry_run:
295
+ with open(valid_path, "w", encoding="utf-8") as f:
296
+ f.write(current_content)
297
+
298
+ return diff_text
299
+
300
+
301
+ def create_directory(path: Annotated[str, "Path to the directory"]) -> str:
302
+ """
303
+ Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently.
304
+ Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.
305
+ Returns: Success message.
306
+ """
307
+ valid_path = _validate_path(path)
308
+
309
+ try:
310
+ os.makedirs(valid_path, exist_ok=True)
311
+ return f"Successfully created directory {path}"
312
+ except Exception as e:
313
+ raise RuntimeError(f"Error creating directory {valid_path}: {e}") from e
314
+
315
+
316
+ def list_directory(path: Annotated[str, "Path to the directory"]) -> str:
317
+ """
318
+ Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes.
319
+ This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
320
+ """
321
+ valid_path = _validate_path(path)
322
+
323
+ if not os.path.exists(valid_path):
324
+ raise FileNotFoundError(f"Directory not found: {valid_path}")
325
+
326
+ if not os.path.isdir(valid_path):
327
+ raise ValueError(f"Path is not a directory: {valid_path}")
328
+
329
+ try:
330
+ entries = sorted(os.listdir(valid_path))
331
+ result = []
332
+ for entry in entries:
333
+ full_path = os.path.join(valid_path, entry)
334
+ if os.path.isdir(full_path):
335
+ result.append(f"[DIR] {entry}")
336
+ else:
337
+ result.append(f"[FILE] {entry}")
338
+ return "\n".join(result)
339
+ except Exception as e:
340
+ raise RuntimeError(f"Error listing directory {valid_path}: {e}") from e
341
+
342
+
343
+ def list_directory_with_sizes(
344
+ path: Annotated[str, "Path to the directory"],
345
+ sort_by: Annotated[Literal["name", "size"], "Sort by name or size"] = "name",
346
+ ) -> str:
347
+ """
348
+ Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes.
349
+ This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
350
+ """
351
+ valid_path = _validate_path(path)
352
+
353
+ if not os.path.exists(valid_path):
354
+ raise FileNotFoundError(f"Directory not found: {valid_path}")
355
+
356
+ try:
357
+ entries = []
358
+ with os.scandir(valid_path) as it:
359
+ for entry in it:
360
+ try:
361
+ stats = entry.stat()
362
+ entries.append(
363
+ {"name": entry.name, "is_dir": entry.is_dir(), "size": stats.st_size, "mtime": stats.st_mtime}
364
+ )
365
+ except OSError:
366
+ # Skip entries we can't stat
367
+ continue
368
+
369
+ # Sort
370
+ if sort_by == "size":
371
+ entries.sort(key=lambda x: x["size"], reverse=True)
372
+ else:
373
+ entries.sort(key=lambda x: x["name"])
374
+
375
+ # Format
376
+ lines = []
377
+ total_files = 0
378
+ total_dirs = 0
379
+ total_size = 0
380
+
381
+ for e in entries:
382
+ prefix = "[DIR] " if e["is_dir"] else "[FILE]"
383
+ name_padded = e["name"].ljust(30)
384
+ size_str = "" if e["is_dir"] else _format_size(e["size"]).rjust(10)
385
+ lines.append(f"{prefix} {name_padded} {size_str}")
386
+
387
+ if e["is_dir"]:
388
+ total_dirs += 1
389
+ else:
390
+ total_files += 1
391
+ total_size += e["size"]
392
+
393
+ # Summary
394
+ lines.append("")
395
+ lines.append(f"Total: {total_files} files, {total_dirs} directories")
396
+ lines.append(f"Combined size: {_format_size(total_size)}")
397
+
398
+ return "\n".join(lines)
399
+ except Exception as e:
400
+ raise RuntimeError(f"Error listing directory {valid_path}: {e}") from e
401
+
402
+
403
+ def directory_tree(
404
+ path: Annotated[str, "Path to the root directory"],
405
+ exclude_patterns: Annotated[List[str], "Glob patterns to exclude"] = None,
406
+ ) -> str:
407
+ """
408
+ Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories.
409
+ Files have no children array, while directories always have a children array (which may be empty).
410
+ The output is formatted with 2-space indentation for readability. Only works within allowed directories.
411
+ """
412
+ import json
413
+
414
+ valid_path = _validate_path(path)
415
+ root_path_len = len(valid_path.rstrip(os.sep)) + 1
416
+ if exclude_patterns is None:
417
+ exclude_patterns = []
418
+
419
+ def _build_tree(current_path: str) -> List[Dict[str, Any]]:
420
+ entries = []
421
+ try:
422
+ with os.scandir(current_path) as it:
423
+ items = sorted(it, key=lambda x: x.name)
424
+
425
+ for entry in items:
426
+ # Check exclusion
427
+ rel_path = entry.path[root_path_len:]
428
+ # Check against patterns
429
+ should_exclude = False
430
+ for pattern in exclude_patterns:
431
+ if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(entry.name, pattern):
432
+ should_exclude = True
433
+ break
434
+ if should_exclude:
435
+ continue
436
+
437
+ entry_data = {"name": entry.name, "type": "directory" if entry.is_dir() else "file"}
438
+
439
+ if entry.is_dir():
440
+ entry_data["children"] = _build_tree(entry.path)
441
+
442
+ entries.append(entry_data)
443
+ except OSError as e:
444
+ logger.warning(f"Error scanning {current_path}: {e}")
445
+
446
+ return entries
447
+
448
+ tree_data = _build_tree(valid_path)
449
+ return json.dumps(tree_data, indent=2)
450
+
451
+
452
+ def move_file(source: Annotated[str, "Source path"], destination: Annotated[str, "Destination path"]) -> str:
453
+ """
454
+ Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail.
455
+ Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.
456
+ """
457
+ valid_source = _validate_path(source)
458
+ valid_dest = _validate_path(destination)
459
+
460
+ try:
461
+ shutil.move(valid_source, valid_dest)
462
+ return f"Successfully moved {source} to {destination}"
463
+ except Exception as e:
464
+ raise RuntimeError(f"Error moving {source} to {destination}: {e}") from e
465
+
466
+
467
+ def search_files(
468
+ path: Annotated[str, "Path to search in"],
469
+ pattern: Annotated[str, "Glob pattern to match"],
470
+ exclude_patterns: Annotated[List[str], "Glob patterns to exclude"] = None,
471
+ ) -> str:
472
+ """
473
+ Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory.
474
+ Use pattern like '.ext' to match files in current directory, and '**/.ext' to match files in all subdirectories.
475
+ Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.
476
+ """
477
+ valid_path = _validate_path(path)
478
+ results = []
479
+ if exclude_patterns is None:
480
+ exclude_patterns = []
481
+
482
+ try:
483
+ for root, dirs, files in os.walk(valid_path):
484
+ # Check exclusions for directories to prune traversal
485
+ dirs[:] = [d for d in dirs if not any(fnmatch.fnmatch(d, pat) for pat in exclude_patterns)]
486
+
487
+ # Check all files and directories
488
+ all_entries = dirs + files
489
+
490
+ for entry in all_entries:
491
+ full_path = os.path.join(root, entry)
492
+ rel_path = os.path.relpath(full_path, valid_path)
493
+
494
+ # Check if matches search pattern
495
+ if fnmatch.fnmatch(entry, pattern) or fnmatch.fnmatch(rel_path, pattern): # noqa: SIM102
496
+ # Double check exclusions (redundant for dirs but safe)
497
+ if not any(
498
+ fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(entry, pat) for pat in exclude_patterns
499
+ ):
500
+ results.append(full_path)
501
+
502
+ except Exception as e:
503
+ raise RuntimeError(f"Error searching files in {valid_path}: {e}") from e
504
+
505
+ if not results:
506
+ return "No matches found"
507
+
508
+ return "\n".join(results)
509
+
510
+
511
+ def get_file_info(path: Annotated[str, "Path to the file"]) -> str:
512
+ """
513
+ Retrieve detailed metadata about a file or directory.
514
+ Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.
515
+ """
516
+ valid_path = _validate_path(path)
517
+
518
+ try:
519
+ stats = os.stat(valid_path)
520
+ is_dir = os.path.isdir(valid_path)
521
+ is_file = os.path.isfile(valid_path)
522
+
523
+ def format_date(timestamp: float) -> str:
524
+ return time.strftime("%a %b %d %Y %H:%M:%S GMT%z (%Z)", time.localtime(timestamp))
525
+
526
+ # Try to get birthtime, fallback to ctime
527
+ created_time = getattr(stats, "st_birthtime", stats.st_ctime)
528
+
529
+ info = [
530
+ f"size: {stats.st_size}",
531
+ f"created: {format_date(created_time)}",
532
+ f"modified: {format_date(stats.st_mtime)}",
533
+ f"accessed: {format_date(stats.st_atime)}",
534
+ f"isDirectory: {'true' if is_dir else 'false'}",
535
+ f"isFile: {'true' if is_file else 'false'}",
536
+ f"permissions: {oct(stats.st_mode)[-3:]}",
537
+ ]
538
+
539
+ return "\n".join(info)
540
+
541
+ except Exception as e:
542
+ raise RuntimeError(f"Error getting file info for {valid_path}: {e}") from e