vmcode-cli 1.0.0
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.
- package/INSTALLATION_METHODS.md +181 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/npm-wrapper.js +171 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +159 -0
- package/package.json +42 -0
- package/requirements.txt +7 -0
- package/scripts/install.js +132 -0
- package/setup.bat +114 -0
- package/setup.sh +135 -0
- package/src/__init__.py +4 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +2342 -0
- package/src/core/chat_manager.py +1201 -0
- package/src/core/config_manager.py +269 -0
- package/src/core/init.py +161 -0
- package/src/core/sub_agent.py +174 -0
- package/src/exceptions.py +75 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +149 -0
- package/src/llm/config.py +445 -0
- package/src/llm/prompts.py +569 -0
- package/src/llm/providers.py +402 -0
- package/src/llm/token_tracker.py +220 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +103 -0
- package/src/ui/commands.py +489 -0
- package/src/ui/displays.py +167 -0
- package/src/ui/main.py +351 -0
- package/src/ui/prompt_utils.py +162 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/markdown.py +32 -0
- package/src/utils/settings.py +94 -0
- package/src/utils/tools/__init__.py +55 -0
- package/src/utils/tools/command_executor.py +217 -0
- package/src/utils/tools/create_file.py +143 -0
- package/src/utils/tools/definitions.py +193 -0
- package/src/utils/tools/directory.py +374 -0
- package/src/utils/tools/file_editor.py +345 -0
- package/src/utils/tools/file_helpers.py +109 -0
- package/src/utils/tools/file_reader.py +331 -0
- package/src/utils/tools/formatters.py +458 -0
- package/src/utils/tools/parallel_executor.py +195 -0
- package/src/utils/validation.py +117 -0
- package/src/utils/web_search.py +71 -0
- package/vmcode-proxy/.env.example +5 -0
- package/vmcode-proxy/README.md +235 -0
- package/vmcode-proxy/package-lock.json +947 -0
- package/vmcode-proxy/package.json +20 -0
- package/vmcode-proxy/server.js +248 -0
- package/vmcode-proxy/server.js.bak +157 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""File reading operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Dict, Tuple
|
|
6
|
+
|
|
7
|
+
from .file_helpers import (
|
|
8
|
+
_is_fast_ignored,
|
|
9
|
+
_is_ignored_cached,
|
|
10
|
+
_register_gitignore_spec,
|
|
11
|
+
_is_reserved_windows_name
|
|
12
|
+
)
|
|
13
|
+
from .formatters import format_file_result
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_read_path(
|
|
17
|
+
path_str: str,
|
|
18
|
+
repo_root: Path,
|
|
19
|
+
gitignore_spec
|
|
20
|
+
) -> Tuple[Optional[Path], Optional[str]]:
|
|
21
|
+
"""Validate and resolve path for reading.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
path_str: Path string to validate
|
|
25
|
+
repo_root: Repository root directory
|
|
26
|
+
gitignore_spec: Optional PathSpec for .gitignore filtering
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
(resolved_path, error_message) - error_message is None if valid
|
|
30
|
+
|
|
31
|
+
Checks:
|
|
32
|
+
- Windows filename validation (invalid chars, reserved names)
|
|
33
|
+
- Path resolution (absolute vs relative)
|
|
34
|
+
- Gitignore filtering (only within repo)
|
|
35
|
+
- Path exists and is a file (not directory)
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# Validate filename for invalid characters
|
|
39
|
+
if os.name == 'nt': # Windows-specific validation
|
|
40
|
+
# Check for invalid characters (includes quotes, brackets that appear in JSON)
|
|
41
|
+
invalid_chars = '<>:"|?*[]{}"\n\r\t'
|
|
42
|
+
if any(char in path_str for char in invalid_chars):
|
|
43
|
+
return None, f"Filename contains invalid characters: {invalid_chars}"
|
|
44
|
+
|
|
45
|
+
# Check for reserved device names
|
|
46
|
+
filename = Path(path_str).name
|
|
47
|
+
if _is_reserved_windows_name(filename):
|
|
48
|
+
return None, f"Filename is a reserved Windows device name: {filename}"
|
|
49
|
+
|
|
50
|
+
# Resolve path
|
|
51
|
+
raw_path = Path(path_str)
|
|
52
|
+
if not raw_path.is_absolute():
|
|
53
|
+
raw_path = repo_root / raw_path
|
|
54
|
+
resolved = raw_path.resolve()
|
|
55
|
+
|
|
56
|
+
# Check .gitignore (only applies to paths within repo)
|
|
57
|
+
if gitignore_spec is not None:
|
|
58
|
+
# Fast-path check first
|
|
59
|
+
if _is_fast_ignored(resolved):
|
|
60
|
+
return None, f"File blocked by .gitignore"
|
|
61
|
+
|
|
62
|
+
# Use cached gitignore check
|
|
63
|
+
spec_key = _register_gitignore_spec(gitignore_spec)
|
|
64
|
+
if _is_ignored_cached(str(resolved), str(repo_root), spec_key):
|
|
65
|
+
return None, f"File blocked by .gitignore"
|
|
66
|
+
|
|
67
|
+
# Check if it's a directory
|
|
68
|
+
if resolved.is_dir():
|
|
69
|
+
return None, "Path is a directory, not a file. Use list_directory instead."
|
|
70
|
+
|
|
71
|
+
return resolved, None
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
return None, str(e)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_start_line(start_line: Optional[int]) -> int:
|
|
78
|
+
"""Validate and normalize start_line parameter.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
start_line: Optional 1-based starting line number
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Normalized start_line (1 or greater)
|
|
85
|
+
"""
|
|
86
|
+
if start_line is None:
|
|
87
|
+
return 1
|
|
88
|
+
try:
|
|
89
|
+
start_line = int(start_line)
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
raise ValueError("start_line must be an integer (1-based).")
|
|
92
|
+
if start_line < 1:
|
|
93
|
+
start_line = 1
|
|
94
|
+
return start_line
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _skip_lines(file_obj, lines_to_skip: int) -> bool:
|
|
98
|
+
"""Advance file_obj by lines_to_skip lines.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
file_obj: File object to advance
|
|
102
|
+
lines_to_skip: Number of lines to skip
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if EOF reached early
|
|
106
|
+
"""
|
|
107
|
+
if lines_to_skip <= 0:
|
|
108
|
+
return False
|
|
109
|
+
remaining = lines_to_skip
|
|
110
|
+
while remaining > 0:
|
|
111
|
+
if file_obj.readline() == "":
|
|
112
|
+
return True
|
|
113
|
+
remaining -= 1
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_full_file(file_path: Path, start_line: int) -> Dict[str, any]:
|
|
118
|
+
"""Read entire file, optionally starting from specific line.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
file_path: Path to file to read
|
|
122
|
+
start_line: 1-based starting line number
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
dict with keys: content, lines_read, truncated=False
|
|
126
|
+
"""
|
|
127
|
+
if start_line == 1:
|
|
128
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
129
|
+
lines_read = len(content.splitlines())
|
|
130
|
+
else:
|
|
131
|
+
lines = []
|
|
132
|
+
with file_path.open("r", encoding="utf-8", errors="replace", newline=None) as f:
|
|
133
|
+
eof_early = _skip_lines(f, start_line - 1)
|
|
134
|
+
if not eof_early:
|
|
135
|
+
lines = f.readlines()
|
|
136
|
+
content = "".join(lines)
|
|
137
|
+
lines_read = len(content.splitlines())
|
|
138
|
+
|
|
139
|
+
return {"content": content, "lines_read": lines_read, "truncated": False}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_partial_file(file_path: Path, start_line: int, max_lines: int) -> Dict[str, any]:
|
|
143
|
+
"""Read partial file content with streaming for large files.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
file_path: Path to file to read
|
|
147
|
+
start_line: 1-based starting line number
|
|
148
|
+
max_lines: Maximum number of lines to read
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
dict with keys: content, lines_read, truncated
|
|
152
|
+
|
|
153
|
+
Strategy:
|
|
154
|
+
- Stream in 8KB chunks
|
|
155
|
+
- Extract complete lines as we go
|
|
156
|
+
- Stop at max_lines
|
|
157
|
+
- Handle pathological long lines (>10MB buffer)
|
|
158
|
+
"""
|
|
159
|
+
lines = []
|
|
160
|
+
truncated = False
|
|
161
|
+
lines_read = 0
|
|
162
|
+
chunk_size = 8192 # 8KB chunks for efficient streaming
|
|
163
|
+
max_buffer_size = 10_000_000 # 10MB limit to handle pathological files (very long single lines)
|
|
164
|
+
|
|
165
|
+
# Use universal newlines so all newline types normalize to '\n' for parsing.
|
|
166
|
+
with file_path.open("r", encoding="utf-8", errors="replace", newline=None) as f:
|
|
167
|
+
eof_early = _skip_lines(f, start_line - 1)
|
|
168
|
+
if eof_early:
|
|
169
|
+
return {"content": "", "lines_read": 0, "truncated": False}
|
|
170
|
+
|
|
171
|
+
if max_lines == 0:
|
|
172
|
+
# Check if file has any content without loading it all
|
|
173
|
+
if f.read(1):
|
|
174
|
+
truncated = True
|
|
175
|
+
else:
|
|
176
|
+
# Streaming read: read in chunks, stop when we have enough lines
|
|
177
|
+
buffer = ""
|
|
178
|
+
eof_reached = False
|
|
179
|
+
while lines_read < max_lines:
|
|
180
|
+
chunk = f.read(chunk_size)
|
|
181
|
+
if not chunk: # EOF reached
|
|
182
|
+
eof_reached = True
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
buffer += chunk
|
|
186
|
+
|
|
187
|
+
parts = buffer.split("\n")
|
|
188
|
+
complete_lines = len(parts) - 1
|
|
189
|
+
remaining_capacity = max_lines - lines_read
|
|
190
|
+
|
|
191
|
+
if complete_lines:
|
|
192
|
+
to_take = min(remaining_capacity, complete_lines)
|
|
193
|
+
for i in range(to_take):
|
|
194
|
+
lines.append(parts[i] + "\n")
|
|
195
|
+
lines_read += to_take
|
|
196
|
+
|
|
197
|
+
if to_take < complete_lines:
|
|
198
|
+
truncated = True
|
|
199
|
+
buffer = ""
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
buffer = parts[-1]
|
|
203
|
+
|
|
204
|
+
# If we've read enough lines and have leftover content, mark as truncated
|
|
205
|
+
if lines_read >= max_lines:
|
|
206
|
+
if buffer:
|
|
207
|
+
truncated = True
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
# Safeguard against extremely long single lines (pathological case)
|
|
211
|
+
if len(buffer) > max_buffer_size:
|
|
212
|
+
lines.append(buffer[:max_buffer_size])
|
|
213
|
+
lines_read += 1
|
|
214
|
+
truncated = True
|
|
215
|
+
buffer = ""
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
if eof_reached and not truncated and buffer and lines_read < max_lines:
|
|
219
|
+
lines.append(buffer)
|
|
220
|
+
lines_read += 1
|
|
221
|
+
buffer = ""
|
|
222
|
+
|
|
223
|
+
if lines_read >= max_lines and not truncated:
|
|
224
|
+
# We may have stopped exactly at a chunk boundary; peek for more content.
|
|
225
|
+
if f.read(1):
|
|
226
|
+
truncated = True
|
|
227
|
+
|
|
228
|
+
content = "".join(lines)
|
|
229
|
+
return {"content": content, "lines_read": lines_read, "truncated": truncated}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_file_content(
|
|
233
|
+
file_path: Path,
|
|
234
|
+
start_line: int,
|
|
235
|
+
max_lines: Optional[int]
|
|
236
|
+
) -> Dict[str, any]:
|
|
237
|
+
"""Read file content with optional line range.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
file_path: Path to file to read
|
|
241
|
+
start_line: 1-based starting line number
|
|
242
|
+
max_lines: Optional maximum number of lines to read
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
dict with keys: content, lines_read, truncated
|
|
246
|
+
|
|
247
|
+
Logic:
|
|
248
|
+
- If max_lines is None: call _read_full_file()
|
|
249
|
+
- Else: call _read_partial_file()
|
|
250
|
+
"""
|
|
251
|
+
if max_lines is None:
|
|
252
|
+
return _read_full_file(file_path, start_line)
|
|
253
|
+
return _read_partial_file(file_path, start_line, max_lines)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def read_file(
|
|
257
|
+
path_str: str,
|
|
258
|
+
repo_root: Path,
|
|
259
|
+
max_lines: Optional[int] = None,
|
|
260
|
+
start_line: Optional[int] = None,
|
|
261
|
+
gitignore_spec = None
|
|
262
|
+
) -> str:
|
|
263
|
+
"""Read a file's contents.
|
|
264
|
+
|
|
265
|
+
Fast file reader that respects .gitignore, supports partial reads via
|
|
266
|
+
max_lines/start_line, and provides consistent output format.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
path_str: Path string to the file to read
|
|
270
|
+
repo_root: Repository root directory (for path resolution)
|
|
271
|
+
max_lines: Optional limit on number of lines to read
|
|
272
|
+
start_line: Optional 1-based starting line number (default: 1)
|
|
273
|
+
gitignore_spec: Optional PathSpec for .gitignore filtering
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
str: Formatted result with exit_code, lines_read, and file content
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
# Validate path
|
|
280
|
+
resolved, error = _validate_read_path(path_str, repo_root, gitignore_spec)
|
|
281
|
+
if error:
|
|
282
|
+
return format_file_result(
|
|
283
|
+
exit_code=1,
|
|
284
|
+
error=error,
|
|
285
|
+
path=path_str
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Validate start_line
|
|
289
|
+
try:
|
|
290
|
+
start_line = _validate_start_line(start_line)
|
|
291
|
+
except ValueError as e:
|
|
292
|
+
return format_file_result(
|
|
293
|
+
exit_code=1,
|
|
294
|
+
error=str(e),
|
|
295
|
+
path=str(resolved.relative_to(repo_root))
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Normalize max_lines
|
|
299
|
+
if max_lines is not None and max_lines < 0:
|
|
300
|
+
max_lines = 0
|
|
301
|
+
|
|
302
|
+
# Read file content
|
|
303
|
+
result = _read_file_content(resolved, start_line, max_lines)
|
|
304
|
+
|
|
305
|
+
return format_file_result(
|
|
306
|
+
exit_code=0,
|
|
307
|
+
content=result["content"],
|
|
308
|
+
path=str(resolved.relative_to(repo_root)),
|
|
309
|
+
lines_read=result["lines_read"],
|
|
310
|
+
start_line=start_line,
|
|
311
|
+
truncated=result["truncated"]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
except FileNotFoundError:
|
|
315
|
+
return format_file_result(
|
|
316
|
+
exit_code=1,
|
|
317
|
+
error="File not found",
|
|
318
|
+
path=path_str
|
|
319
|
+
)
|
|
320
|
+
except PermissionError:
|
|
321
|
+
return format_file_result(
|
|
322
|
+
exit_code=1,
|
|
323
|
+
error="Permission denied",
|
|
324
|
+
path=path_str
|
|
325
|
+
)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
return format_file_result(
|
|
328
|
+
exit_code=1,
|
|
329
|
+
error=str(e),
|
|
330
|
+
path=path_str
|
|
331
|
+
)
|