File size: 9,423 Bytes
e066621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
from __future__ import annotations

import ast
import pathlib
import re
import subprocess
from typing import List, Tuple

from langchain_core.tools import tool

PROJECT_ROOT_DEFAULT = (pathlib.Path.cwd() / "generated_project").resolve()
PROJECT_ROOT = PROJECT_ROOT_DEFAULT


def safe_path_for_project(path: str) -> pathlib.Path:
    p = (PROJECT_ROOT / path).resolve()
    if PROJECT_ROOT.resolve() not in p.parents and PROJECT_ROOT.resolve() != p.parent and PROJECT_ROOT.resolve() != p:
        raise ValueError("Attempt to write outside project root")
    return p


@tool
def write_file(path: str, content: str) -> str:
    """Writes content to a file at the specified path within the project root."""
    p = safe_path_for_project(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    with open(p, "w", encoding="utf-8") as f:
        f.write(content)
    return f"WROTE:{p}"


@tool
def read_file(path: str) -> str:
    """Reads content from a file at the specified path within the project root."""
    p = safe_path_for_project(path)
    if not p.exists():
        return ""
    with open(p, "r", encoding="utf-8") as f:
        return f.read()


@tool
def get_current_directory() -> str:
    """Returns the current working directory."""
    return str(PROJECT_ROOT)


@tool
def list_files(directory: str = ".") -> str:
    """Lists all files in the specified directory within the project root."""
    p = safe_path_for_project(directory)
    if not p.is_dir():
        return f"ERROR: {p} is not a directory"
    files = [str(f.relative_to(PROJECT_ROOT)) for f in p.glob("**/*") if f.is_file()]
    return "\n".join(files) if files else "No files found."


@tool
def print_tree(path: str = ".", depth: int = 2) -> str:
    """
    Prints a directory tree for the given path up to the specified depth.
    """
    depth = max(1, depth)
    target = safe_path_for_project(path)
    if not target.exists():
        return f"ERROR: Path does not exist: {target}"

    lines = []
    start_depth = len(target.parts)
    for entry in sorted(target.rglob("*")):
        rel_depth = len(entry.parts) - start_depth
        if rel_depth > depth:
            continue
        indent = "    " * rel_depth
        name = entry.name + ("/" if entry.is_dir() else "")
        lines.append(f"{indent}{name}")

    if target.is_file():
        return f"{target.name} (file)"

    header = f"Tree for {target.relative_to(PROJECT_ROOT) if target != PROJECT_ROOT else '.'}"
    body = "\n".join(lines) if lines else "(empty directory)"
    return f"{header}\n{body}"


@tool
def search_files(query: str, path: str = ".", max_results: int = 20) -> str:
    """
    Searches for the query string inside files rooted at the given path.
    Returns up to max_results matches with file path and line content.
    """
    p = safe_path_for_project(path)
    if not p.exists():
        return f"ERROR: Path does not exist: {p}"

    matches = []
    files = [p] if p.is_file() else sorted(f for f in p.rglob("*") if f.is_file())
    for file_path in files:
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                for line_num, line in enumerate(f, 1):
                    if query in line:
                        rel_path = file_path.relative_to(PROJECT_ROOT)
                        matches.append(f"{rel_path}:{line_num}: {line.rstrip()}")
                        if len(matches) >= max_results:
                            return "\n".join(matches)
        except UnicodeDecodeError:
            continue

    return "\n".join(matches) if matches else "No matches found."


def _extract_python_symbols(content: str) -> Tuple[List[str], List[str]]:
    classes: List[str] = []
    functions: List[str] = []
    try:
        tree = ast.parse(content)
    except SyntaxError:
        return classes, functions

    for node in tree.body:
        if isinstance(node, ast.ClassDef):
            doc = ast.get_docstring(node)
            snippet = f" - {doc[:60].strip()}" if doc else ""
            classes.append(f"{node.name}{snippet}")
        elif isinstance(node, ast.FunctionDef):
            doc = ast.get_docstring(node)
            snippet = f" - {doc[:60].strip()}" if doc else ""
            functions.append(f"{node.name}{snippet}")
    return classes, functions


def _extract_generic_symbols(content: str) -> Tuple[List[str], List[str]]:
    class_pattern = re.compile(r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_][\w]*)", re.MULTILINE)
    func_pattern = re.compile(r"^\s*(?:export\s+)?(?:async\s+)?(?:def|function)\s+([A-Za-z_][\w]*)", re.MULTILINE)
    arrow_pattern = re.compile(r"^\s*const\s+([A-Za-z_][\w]*)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>", re.MULTILINE)
    classes = class_pattern.findall(content)
    functions = sorted(set(func_pattern.findall(content)) | set(arrow_pattern.findall(content)))
    return classes, functions


def _summarize_file(path: pathlib.Path) -> str:
    try:
        content = path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        content = path.read_text(encoding="latin-1", errors="ignore")

    relative_path = path.relative_to(PROJECT_ROOT)
    lines = content.count("\n") + 1 if content else 0
    size = path.stat().st_size

    if path.suffix == ".py":
        classes, functions = _extract_python_symbols(content)
    else:
        classes, functions = _extract_generic_symbols(content)

    snippet = ""
    for line in content.splitlines():
        stripped = line.strip()
        if stripped:
            snippet = stripped
            break

    file_summary = [
        f"- {relative_path} (lines={lines}, bytes={size})"
    ]
    if classes:
        file_summary.append(f"  Classes: {', '.join(classes[:5])}")
    if functions:
        file_summary.append(f"  Functions: {', '.join(functions[:5])}")
    if snippet:
        file_summary.append(f"  First code line: {snippet[:120]}")
    return "\n".join(file_summary)


def _build_directory_tree(directory: pathlib.Path) -> str:
    lines = []
    for path in sorted(directory.rglob("*")):
        depth = len(path.relative_to(directory).parts)
        prefix = "    " * depth
        name = path.name + ("/" if path.is_dir() else "")
        lines.append(f"{prefix}{name}")
    return "No files found." if not lines else "\n".join(lines)


@tool
def summarize_project(directory: str = ".") -> str:
    """Summarizes the structure of a directory under the project root, including files, classes, and functions."""
    target = safe_path_for_project(directory)
    if not target.exists():
        return f"Directory {directory} does not exist under project root."

    if target.is_file():
        return _summarize_file(target)

    tree = _build_directory_tree(target)
    summaries = []
    file_count = 0
    MAX_FILES = 200
    for path in sorted(target.rglob("*")):
        if path.is_file():
            summaries.append(_summarize_file(path))
            file_count += 1
            if file_count >= MAX_FILES:
                summaries.append("...output truncated after 200 files...")
                break

    rel_target = "." if target == PROJECT_ROOT else str(target.relative_to(PROJECT_ROOT))
    header = [
        f"PROJECT SUMMARY: {rel_target}",
        "",
        "DIRECTORY TREE:",
        tree,
        "",
        "FILE SUMMARIES:",
        "\n".join(summaries) if summaries else "No files found in this directory."
    ]
    return "\n".join(header)


@tool
def run_cmd(cmd: str, cwd: str = None, timeout: int = 30) -> Tuple[int, str, str]:
    """Runs a shell command in the specified directory and returns the result."""
    cwd_dir = safe_path_for_project(cwd) if cwd else PROJECT_ROOT
    res = subprocess.run(cmd, shell=True, cwd=str(cwd_dir), capture_output=True, text=True, timeout=timeout)
    return res.returncode, res.stdout, res.stderr


def init_project_root(path: str | pathlib.Path | None = None) -> str:
    """
    Sets the project root to the provided path (absolute or relative). Defaults to the generated_project folder.
    Returns the absolute path that will be used by all tools.
    """
    global PROJECT_ROOT
    if path:
        target = pathlib.Path(path).expanduser()
        if not target.is_absolute():
            target = (pathlib.Path.cwd() / target).resolve()
        else:
            target = target.resolve()
    else:
        target = PROJECT_ROOT_DEFAULT.resolve()

    PROJECT_ROOT = target
    PROJECT_ROOT.mkdir(parents=True, exist_ok=True)
    return str(PROJECT_ROOT)

@tool
def edit_file(path: str, updated_content: str) -> str:
    """
    Edits an existing file within the project root by replacing its content.
    Unlike write_file, this will NOT create the file if it doesn't exist.
    """
    p = safe_path_for_project(path)

    if not p.exists():
        return f"ERROR: File does not exist: {p}"

    if p.is_dir():
        return f"ERROR: {p} is a directory, not a file"

    with open(p, "w", encoding="utf-8") as f:
        f.write(updated_content)

    return f"UPDATED:{p}"

@tool
def delete_file(path: str) -> str:
    """
    Deletes a file within the project root.
    Will NOT delete directories.
    Will NOT operate outside project root.
    """
    p = safe_path_for_project(path)

    if not p.exists():
        return f"ERROR: File does not exist: {p}"

    if p.is_dir():
        return f"ERROR: {p} is a directory, not a file"

    p.unlink()
    return f"DELETED:{p}"