skydeckai-code 0.1.38__py3-none-any.whl → 0.1.40__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.
@@ -2,9 +2,9 @@ import json
2
2
  import os
3
3
  import subprocess
4
4
  from datetime import datetime
5
-
5
+ import asyncio
6
6
  from mcp.types import TextContent
7
-
7
+ from pathlib import Path
8
8
  from .state import state
9
9
 
10
10
 
@@ -12,12 +12,12 @@ def list_directory_tool():
12
12
  return {
13
13
  "name": "list_directory",
14
14
  "description": "Get a detailed listing of files and directories in the specified path, including type, size, and modification "
15
- "date. WHEN TO USE: When you need to explore the contents of a directory, understand what files are available, check file sizes or "
16
- "modification dates, or locate specific files by name. WHEN NOT TO USE: When you need to read the contents of files (use read_file "
17
- "instead), when you need a recursive listing of all subdirectories (use directory_tree instead), or when searching for files by name pattern "
18
- "(use search_files instead). RETURNS: Text with each line containing file type ([DIR]/[FILE]), name, size (in B/KB/MB), and "
19
- "modification date. Only works within the allowed directory. Example: Enter 'src' to list contents of the src directory, or '.' for "
20
- "current directory.",
15
+ "date. WHEN TO USE: When you need to explore the contents of a directory, understand what files are available, check file sizes or "
16
+ "modification dates, or locate specific files by name. WHEN NOT TO USE: When you need to read the contents of files (use read_file "
17
+ "instead), when you need a recursive listing of all subdirectories (use directory_tree instead), or when searching for files by name pattern "
18
+ "(use search_files instead). RETURNS: Text with each line containing file type ([DIR]/[FILE]), name, size (in B/KB/MB), and "
19
+ "modification date. Only works within the allowed directory. Example: Enter 'src' to list contents of the src directory, or '.' for "
20
+ "current directory.",
21
21
  "inputSchema": {
22
22
  "type": "object",
23
23
  "properties": {
@@ -26,10 +26,11 @@ def list_directory_tool():
26
26
  "description": "Path of the directory to list. Examples: '.' for current directory, 'src' for src directory, 'docs/images' for a nested directory. The path must be within the allowed workspace.",
27
27
  }
28
28
  },
29
- "required": ["path"]
29
+ "required": ["path"],
30
30
  },
31
31
  }
32
32
 
33
+
33
34
  async def handle_list_directory(arguments: dict):
34
35
  from mcp.types import TextContent
35
36
 
@@ -77,29 +78,31 @@ async def handle_list_directory(arguments: dict):
77
78
  except PermissionError:
78
79
  raise ValueError(f"Permission denied accessing: {full_path}")
79
80
 
81
+
80
82
  def create_directory_tool():
81
83
  return {
82
84
  "name": "create_directory",
83
85
  "description": "Create a new directory or ensure a directory exists. "
84
- "Can create multiple nested directories in one operation. "
85
- "WHEN TO USE: When you need to set up project structure, organize files, create output directories before saving files, or establish a directory hierarchy. "
86
- "WHEN NOT TO USE: When you only want to check if a directory exists (use get_file_info instead), or when trying to create directories outside the allowed workspace. "
87
- "RETURNS: Text message confirming either that the directory was successfully created or that it already exists. "
88
- "The operation succeeds silently if the directory already exists. "
89
- "Only works within the allowed directory. "
90
- "Example: Enter 'src/components' to create nested directories.",
86
+ "Can create multiple nested directories in one operation. "
87
+ "WHEN TO USE: When you need to set up project structure, organize files, create output directories before saving files, or establish a directory hierarchy. "
88
+ "WHEN NOT TO USE: When you only want to check if a directory exists (use get_file_info instead), or when trying to create directories outside the allowed workspace. "
89
+ "RETURNS: Text message confirming either that the directory was successfully created or that it already exists. "
90
+ "The operation succeeds silently if the directory already exists. "
91
+ "Only works within the allowed directory. "
92
+ "Example: Enter 'src/components' to create nested directories.",
91
93
  "inputSchema": {
92
94
  "type": "object",
93
95
  "properties": {
94
96
  "path": {
95
97
  "type": "string",
96
- "description": "Path of the directory to create. Can include nested directories which will all be created. Examples: 'logs' for a simple directory, 'src/components/buttons' for nested directories. Both absolute and relative paths are supported, but must be within the allowed workspace."
98
+ "description": "Path of the directory to create. Can include nested directories which will all be created. Examples: 'logs' for a simple directory, 'src/components/buttons' for nested directories. Both absolute and relative paths are supported, but must be within the allowed workspace.",
97
99
  }
98
100
  },
99
- "required": ["path"]
101
+ "required": ["path"],
100
102
  },
101
103
  }
102
104
 
105
+
103
106
  async def handle_create_directory(arguments: dict):
104
107
  """Handle creating a new directory."""
105
108
  from mcp.types import TextContent
@@ -117,9 +120,7 @@ async def handle_create_directory(arguments: dict):
117
120
 
118
121
  # Security check: ensure path is within allowed directory
119
122
  if not full_path.startswith(state.allowed_directory):
120
- raise ValueError(
121
- f"Access denied: Path ({full_path}) must be within allowed directory ({state.allowed_directory})"
122
- )
123
+ raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory ({state.allowed_directory})")
123
124
 
124
125
  already_exists = os.path.exists(full_path)
125
126
 
@@ -129,72 +130,48 @@ async def handle_create_directory(arguments: dict):
129
130
 
130
131
  if already_exists:
131
132
  return [TextContent(type="text", text=f"Directory already exists: {path}")]
132
- return [TextContent(
133
- type="text",
134
- text=f"Successfully created directory: {path}"
135
- )]
133
+ return [TextContent(type="text", text=f"Successfully created directory: {path}")]
136
134
  except PermissionError:
137
135
  raise ValueError(f"Permission denied creating directory: {path}")
138
136
  except Exception as e:
139
137
  raise ValueError(f"Error creating directory: {str(e)}")
140
138
 
139
+
141
140
  def directory_tree_tool():
142
141
  return {
143
142
  "name": "directory_tree",
144
143
  "description": "Get a recursive tree view of files and directories in the specified path as a JSON structure. "
145
- "WHEN TO USE: When you need to understand the complete structure of a directory tree, visualize the hierarchy of files and directories, or get a comprehensive overview of a project's organization. "
146
- "Particularly useful for large projects where you need to see nested relationships. "
147
- "WHEN NOT TO USE: When you only need a flat list of files in a single directory (use directory_listing instead), or when you're only interested in specific file types (use search_files instead). "
148
- "RETURNS: JSON structure where each entry includes 'name', 'type' (file/directory), and 'children' for directories. "
149
- "Files have no children array, while directories always have a children array (which may be empty). "
150
- "The output is formatted with 2-space indentation for readability. For Git repositories, shows tracked files only. "
151
- "Only works within the allowed directory. "
152
- "Example: Enter '.' for current directory, or 'src' for a specific directory.",
144
+ "WHEN TO USE: When you need to understand the complete structure of a directory tree, visualize the hierarchy of files and directories, or get a comprehensive overview of a project's organization. "
145
+ "Particularly useful for large projects where you need to see nested relationships. "
146
+ "WHEN NOT TO USE: When you only need a flat list of files in a single directory (use directory_listing instead), or when you're only interested in specific file types (use search_files instead). "
147
+ "RETURNS: JSON structure where each entry includes 'name', 'type' (file/directory), and 'children' for directories. "
148
+ "Files have no children array, while directories always have a children array (which may be empty). "
149
+ "The output is formatted with 2-space indentation for readability. For Git repositories, shows tracked files only. "
150
+ "Only works within the allowed directory and only for non-hidden files, or files that are not inside hidden directory. "
151
+ "If you want to show the hidden files also, use commands like execute_shell_script. "
152
+ "Example: Enter '.' for current directory, or 'src' for a specific directory.",
153
153
  "inputSchema": {
154
154
  "type": "object",
155
155
  "properties": {
156
156
  "path": {
157
157
  "type": "string",
158
- "description": "Root directory to analyze. This is the starting point for the recursive tree generation. Examples: '.' for current directory, 'src' for the src directory. Both absolute and relative paths are supported, but must be within the allowed workspace."
159
- }
158
+ "description": "Root directory to analyze. This is the starting point for the recursive tree generation. Examples: '.' for current directory, 'src' for the src directory. Both absolute and relative paths are supported, but must be within the allowed workspace.",
159
+ },
160
+ "max_depth": {
161
+ "type": "integer",
162
+ "description": "Max depth for traversing in case of big and deeply nested directory",
163
+ "default": 3,
164
+ },
160
165
  },
161
- "required": ["path"]
166
+ "required": ["path"],
162
167
  },
163
168
  }
164
169
 
165
- async def build_directory_tree(dir_path: str) -> dict:
166
- """Build directory tree as a JSON structure."""
167
- try:
168
- entries = list(os.scandir(dir_path))
169
- # Sort entries by name
170
- entries.sort(key=lambda e: e.name.lower())
171
-
172
- result = {
173
- "name": os.path.basename(dir_path) or dir_path,
174
- "type": "directory",
175
- "children": []
176
- }
177
-
178
- for entry in entries:
179
- if entry.is_dir():
180
- # Recursively process subdirectories
181
- child_tree = await build_directory_tree(entry.path)
182
- result["children"].append(child_tree)
183
- else:
184
- result["children"].append({
185
- "name": entry.name,
186
- "type": "file"
187
- })
188
-
189
- return result
190
- except PermissionError:
191
- raise ValueError(f"Access denied: {dir_path}")
192
- except Exception as e:
193
- raise ValueError(f"Error processing directory {dir_path}: {str(e)}")
194
170
 
195
171
  async def handle_directory_tree(arguments: dict):
196
172
  """Handle building a directory tree."""
197
173
  path = arguments.get("path", ".")
174
+ max_depth = arguments.get("max_depth", 3)
198
175
 
199
176
  # Validate and get full path
200
177
  full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
@@ -205,85 +182,253 @@ async def handle_directory_tree(arguments: dict):
205
182
  if not os.path.isdir(full_path):
206
183
  raise ValueError(f"Path is not a directory: {full_path}")
207
184
 
208
- # Try git ls-files first
185
+ """
186
+ Idea: for git repo directory, use git ls-files to list all the files
187
+ So that we can avoid some gigantic directories like node_modules, build, dist
188
+ Else just use normal listing
189
+ 1. Try git ls-files for this directory
190
+ 2. If failed, identify git repo by rg and sed -> find -> python,
191
+ git ls-files then add to the visited
192
+ 3. List the remaining that is not in visited using rg -> find -> python
193
+ """
194
+ root = {"name": full_path, "type": "directory", "children": []}
195
+ dir_cache = {"": root}
209
196
  try:
210
- # Get list of all files tracked by git
211
- result = subprocess.run(
212
- ['git', 'ls-files'],
213
- cwd=full_path,
214
- capture_output=True,
215
- text=True,
216
- check=True,
217
- )
218
-
219
- # If git command was successful
220
- files = [f for f in result.stdout.split('\n') if f.strip()]
221
- files.sort()
222
-
223
- # Build tree from git files
224
- directory_map = {}
225
- root_name = os.path.basename(full_path) or full_path
226
-
227
- # First pass: collect all directories and files
228
- for file in files:
229
- parts = file.split(os.sep)
230
- # Add all intermediate directories
231
- for i in range(len(parts)):
232
- parent = os.sep.join(parts[:i])
233
- os.sep.join(parts[:i+1])
234
- if i < len(parts) - 1: # It's a directory
235
- directory_map.setdefault(parent, {"dirs": set(), "files": set()})["dirs"].add(parts[i])
236
- else: # It's a file
237
- directory_map.setdefault(parent, {"dirs": set(), "files": set()})["files"].add(parts[i])
238
-
239
- async def build_git_tree(current_path: str) -> dict:
240
- dir_name = current_path.split(os.sep)[-1] if current_path else ''
241
- result = {
242
- "name": dir_name or root_name,
243
- "type": "directory",
244
- "children": [],
245
- }
246
-
247
- if current_path not in directory_map:
248
- return result
249
-
250
- entry = directory_map[current_path]
251
-
252
- # Add directories first
253
- for dir_name in sorted(entry["dirs"]):
254
- child_path = os.path.join(current_path, dir_name) if current_path else dir_name
255
- child_tree = await build_git_tree(child_path)
256
- result["children"].append(child_tree)
257
-
258
- # Then add files
259
- for file_name in sorted(entry["files"]):
260
- result["children"].append({
261
- "name": file_name,
262
- "type": "file",
263
- })
264
-
265
- return result
266
-
267
- # Build the tree structure starting from root
268
- tree = await build_git_tree('')
269
- return [TextContent(type="text", text=json.dumps(tree, indent=2))]
270
-
271
- except (subprocess.CalledProcessError, FileNotFoundError):
272
- # Git not available or not a git repository, use fallback implementation
197
+ paths = await git_ls(Path(full_path))
198
+ build_tree_from_paths(root, dir_cache, paths, max_depth)
199
+ json_tree = json.dumps(root, indent=2)
200
+ return [TextContent(type="text", text=json_tree)]
201
+ except Exception:
273
202
  pass
274
- except Exception as e:
275
- # Log the error but continue with fallback
276
- print(f"Error using git ls-files: {e}")
203
+
204
+ # build the tree for git repo
205
+ try:
206
+ git_repos = await find_git_repo_async(full_path)
207
+ except Exception:
208
+ git_repos = find_git_repos_python(Path(full_path))
209
+ for git_repo in git_repos:
210
+ absolute_git_repo = Path(full_path) / git_repo
211
+ paths = []
212
+ try:
213
+ paths = await git_ls(absolute_git_repo)
214
+ except Exception:
215
+ try:
216
+ paths = await scan_path_async([], absolute_git_repo)
217
+ except Exception:
218
+ paths = scan_path([], absolute_git_repo)
219
+ finally:
220
+ paths = [git_repo / path for path in paths]
221
+ build_tree_from_paths(root, dir_cache, paths, max_depth)
222
+
223
+ # for non-git directory, do normal scan
224
+ non_git_scans = []
225
+ try:
226
+ non_git_scans = await scan_path_async(git_repos, Path(full_path))
227
+ except Exception:
228
+ non_git_scans = scan_path(git_repos, Path(full_path))
229
+ finally:
230
+ build_tree_from_paths(root, dir_cache, non_git_scans, max_depth)
231
+ json_tree = json.dumps(root, indent=2)
232
+ return [TextContent(type="text", text=json_tree)]
233
+
234
+
235
+ async def find_git_repo_async(cwd: str) -> list[Path]:
236
+ # ripgrep first then find
237
+ try:
238
+ cmd = r"rg --files --glob '**/.git/HEAD' --hidden | sed 's|/\.git/HEAD$|/.git|'"
239
+ proc = await asyncio.create_subprocess_shell(
240
+ cmd,
241
+ cwd=cwd,
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.PIPE,
244
+ )
245
+ stdout, stderr = await proc.communicate()
246
+
247
+ if proc.returncode not in [0, 1]: # 0 = success, 1 = some files not found (normal)
248
+ stderr_text = stderr.decode().strip()
249
+ if stderr_text: # If there's stderr content, it's likely a real error
250
+ raise Exception(f"Find command error: {stderr_text}")
251
+
252
+ git_dirs = stdout.decode().strip().splitlines()
253
+ repo_paths: list[Path] = []
254
+ for git_dir in git_dirs:
255
+ if git_dir: # Skip empty lines
256
+ # Convert to Path object and get parent (removes .git)
257
+ repo_relative_path = Path(git_dir).parent
258
+ repo_paths.append(repo_relative_path)
259
+ return repo_paths
260
+
261
+ except Exception:
277
262
  pass
278
263
 
279
- # Fallback to regular directory traversal
264
+ cmd = r"find . -name .git -type d ! -path '*/\.*/*'"
265
+ proc = await asyncio.create_subprocess_shell(
266
+ cmd,
267
+ cwd=cwd,
268
+ stdout=subprocess.PIPE,
269
+ stderr=subprocess.PIPE,
270
+ )
271
+ stdout, stderr = await proc.communicate()
272
+
273
+ if proc.returncode not in [0, 1]: # 0 = success, 1 = some files not found (normal)
274
+ stderr_text = stderr.decode().strip()
275
+ if stderr_text: # If there's stderr content, it's likely a real error
276
+ raise Exception(f"Find command error: {stderr_text}")
277
+
278
+ git_dirs = stdout.decode().strip().splitlines()
279
+ repo_paths: list[Path] = []
280
+
281
+ for git_dir in git_dirs:
282
+ if git_dir: # Skip empty lines
283
+ # Convert to Path object and get parent (removes .git)
284
+ repo_relative_path = Path(git_dir).parent
285
+ repo_paths.append(repo_relative_path)
286
+ return repo_paths
287
+
288
+
289
+ def find_git_repos_python(start_path: Path) -> list[Path]:
290
+ r"""
291
+ Python fallback for: find . -name .git -type d ! -path '*/\.*/*'
292
+
293
+ Finds all .git directories, excluding those inside hidden directories.
294
+
295
+ Args:
296
+ start_path: Starting directory (defaults to current directory)
297
+
298
+ Returns:
299
+ List of Path objects pointing to .git directories
300
+ """
301
+ git_dirs = []
302
+ start_str = str(start_path)
303
+
304
+ for root, dirs, _ in os.walk(start_str, followlinks=False):
305
+ # Remove hidden directories from traversal
306
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
307
+
308
+ # Check if current directory contains .git
309
+ if ".git" in dirs:
310
+ # Calculate relative path
311
+ rel_root = os.path.relpath(root, start_str)
312
+ if rel_root == ".":
313
+ git_path = ".git"
314
+ else:
315
+ git_path = rel_root + "/.git"
316
+
317
+ git_dirs.append(Path(git_path))
318
+
319
+ # Remove .git from further traversal (we don't need to go inside it)
320
+ dirs.remove(".git")
321
+
322
+ return git_dirs
323
+
324
+
325
+ async def git_ls(git_cwd: Path) -> list[Path]:
326
+ cmd = r"git ls-files"
327
+ proc = await asyncio.create_subprocess_shell(cmd, cwd=git_cwd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
328
+ stdout, stderr = await proc.communicate()
329
+ if proc.returncode != 0:
330
+ stderr_text = stderr.decode().strip()
331
+ raise Exception(f"Command error with status {proc.returncode}: {stderr_text}")
332
+
333
+ paths = stdout.decode().strip().splitlines()
334
+ paths = [Path(path) for path in paths if path]
335
+ return paths
336
+
337
+
338
+ def build_tree_from_paths(root: dict, dir_cache: dict, paths: list[Path], max_depth: int):
339
+ paths = [path for path in paths if len(path.parts) <= max_depth]
340
+
341
+ for path in paths:
342
+ parts = path.parts
343
+ current_path = ""
344
+ current = root
345
+ n = len(parts)
346
+
347
+ for i, part in enumerate(parts):
348
+ if i == n - 1:
349
+ current["children"].append({"name": part, "type": "file"})
350
+ else:
351
+ current_path = str(Path(current_path) / part) if current_path else part
352
+
353
+ if current_path not in dir_cache:
354
+ new_dir = {"name": part, "type": "directory", "children": []}
355
+ current["children"].append(new_dir)
356
+ dir_cache[current_path] = new_dir
357
+
358
+ current = dir_cache[current_path]
359
+
360
+
361
+ def scan_path(ignore_paths: list[Path], cwd: Path) -> list[Path]:
362
+ # ignore_paths relative to cwd
363
+ ignore_absolute = {(cwd / ignore_path).resolve() for ignore_path in ignore_paths}
364
+ files: list[Path] = []
365
+ for root, dirs, filenames in os.walk(cwd):
366
+ root_path = Path(root)
367
+
368
+ # Remove hidden directories from dirs list (modifies os.walk behavior)
369
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
370
+
371
+ # Remove ignored directories from dirs list
372
+ dirs[:] = [d for d in dirs if (root_path / d).resolve() not in ignore_absolute]
373
+
374
+ # Add non-hidden files
375
+ for filename in filenames:
376
+ if not filename.startswith("."):
377
+ file_path = root_path / filename
378
+ # Return path relative to cwd
379
+ files.append(file_path.relative_to(cwd))
380
+
381
+ return files
382
+
383
+
384
+ async def scan_path_async(ignore_paths: list[Path], cwd: Path) -> list[Path]:
385
+ # try ripgrep first, then find
280
386
  try:
281
- # Build the directory tree structure
282
- tree = await build_directory_tree(full_path)
387
+ rgignore = " ".join(f"--glob '!{path}/**'" for path in ignore_paths)
388
+ rgcmd = rf"rg --files {rgignore} ."
389
+
390
+ proc = await asyncio.create_subprocess_shell(
391
+ rgcmd,
392
+ cwd=cwd,
393
+ stdout=asyncio.subprocess.PIPE,
394
+ stderr=asyncio.subprocess.PIPE,
395
+ )
283
396
 
284
- # Convert to JSON with pretty printing
285
- json_tree = json.dumps(tree, indent=2)
397
+ stdout, stderr = await proc.communicate()
286
398
 
287
- return [TextContent(type="text", text=json_tree)]
288
- except Exception as e:
289
- raise ValueError(f"Error building directory tree: {str(e)}")
399
+ if proc.returncode in [0, 1]:
400
+ paths = []
401
+ for line in stdout.decode().strip().splitlines():
402
+ if line:
403
+ paths.append(Path(line))
404
+ return paths
405
+ except Exception:
406
+ pass
407
+
408
+ ignore_paths += [Path("backend")]
409
+
410
+ findignore = " ".join(f"-path './{path}' -prune -o" for path in ignore_paths)
411
+ findcmd = f"find . {findignore} -type f ! -path '*/.*/*' ! -name '.*' -print"
412
+
413
+ proc = await asyncio.create_subprocess_shell(
414
+ findcmd,
415
+ cwd=cwd,
416
+ stdout=subprocess.PIPE,
417
+ stderr=subprocess.PIPE,
418
+ )
419
+ stdout, stderr = await proc.communicate()
420
+
421
+ if proc.returncode not in [0, 1]: # 0 = success, 1 = some files not found (normal)
422
+ stderr_text = stderr.decode().strip()
423
+ if stderr_text: # If there's stderr content, it's likely a real error
424
+ raise Exception(f"Find command error: {stderr_text}")
425
+
426
+ paths = []
427
+ for line in stdout.decode().strip().splitlines():
428
+ if line:
429
+ if line.startswith("./"):
430
+ line = line[2:]
431
+ if line:
432
+ paths.append(Path(line))
433
+
434
+ return paths