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.
Files changed (56) hide show
  1. package/INSTALLATION_METHODS.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/bin/npm-wrapper.js +171 -0
  5. package/bin/rg +0 -0
  6. package/bin/rg.exe +0 -0
  7. package/config.yaml.example +159 -0
  8. package/package.json +42 -0
  9. package/requirements.txt +7 -0
  10. package/scripts/install.js +132 -0
  11. package/setup.bat +114 -0
  12. package/setup.sh +135 -0
  13. package/src/__init__.py +4 -0
  14. package/src/core/__init__.py +1 -0
  15. package/src/core/agentic.py +2342 -0
  16. package/src/core/chat_manager.py +1201 -0
  17. package/src/core/config_manager.py +269 -0
  18. package/src/core/init.py +161 -0
  19. package/src/core/sub_agent.py +174 -0
  20. package/src/exceptions.py +75 -0
  21. package/src/llm/__init__.py +1 -0
  22. package/src/llm/client.py +149 -0
  23. package/src/llm/config.py +445 -0
  24. package/src/llm/prompts.py +569 -0
  25. package/src/llm/providers.py +402 -0
  26. package/src/llm/token_tracker.py +220 -0
  27. package/src/ui/__init__.py +1 -0
  28. package/src/ui/banner.py +103 -0
  29. package/src/ui/commands.py +489 -0
  30. package/src/ui/displays.py +167 -0
  31. package/src/ui/main.py +351 -0
  32. package/src/ui/prompt_utils.py +162 -0
  33. package/src/utils/__init__.py +1 -0
  34. package/src/utils/editor.py +158 -0
  35. package/src/utils/gitignore_filter.py +149 -0
  36. package/src/utils/logger.py +254 -0
  37. package/src/utils/markdown.py +32 -0
  38. package/src/utils/settings.py +94 -0
  39. package/src/utils/tools/__init__.py +55 -0
  40. package/src/utils/tools/command_executor.py +217 -0
  41. package/src/utils/tools/create_file.py +143 -0
  42. package/src/utils/tools/definitions.py +193 -0
  43. package/src/utils/tools/directory.py +374 -0
  44. package/src/utils/tools/file_editor.py +345 -0
  45. package/src/utils/tools/file_helpers.py +109 -0
  46. package/src/utils/tools/file_reader.py +331 -0
  47. package/src/utils/tools/formatters.py +458 -0
  48. package/src/utils/tools/parallel_executor.py +195 -0
  49. package/src/utils/validation.py +117 -0
  50. package/src/utils/web_search.py +71 -0
  51. package/vmcode-proxy/.env.example +5 -0
  52. package/vmcode-proxy/README.md +235 -0
  53. package/vmcode-proxy/package-lock.json +947 -0
  54. package/vmcode-proxy/package.json +20 -0
  55. package/vmcode-proxy/server.js +248 -0
  56. 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
+ )