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,345 @@
1
+ """File editing operations with preview and diff generation."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from exceptions import PathValidationError, FileEditError
7
+
8
+ from .formatters import _build_diff, _detect_newline, _normalize_search_replace_for_newlines
9
+
10
+ _WHITESPACE_RE = re.compile(r"\s+")
11
+
12
+
13
+ def _normalize_line_for_match(line, *, collapse_whitespace):
14
+ line = line.rstrip("\r\n")
15
+ if collapse_whitespace:
16
+ return _WHITESPACE_RE.sub(" ", line).strip()
17
+ return line.rstrip(" \t")
18
+
19
+
20
+ def _find_spans_by_line_normalization(content, search_text, *, collapse_whitespace):
21
+ file_lines = content.splitlines(keepends=True)
22
+ search_lines = search_text.splitlines(keepends=False)
23
+ if not search_lines:
24
+ return []
25
+
26
+ normalized_search = [
27
+ _normalize_line_for_match(line, collapse_whitespace=collapse_whitespace)
28
+ for line in search_lines
29
+ ]
30
+ normalized_file = [
31
+ _normalize_line_for_match(line, collapse_whitespace=collapse_whitespace)
32
+ for line in file_lines
33
+ ]
34
+
35
+ offsets = [0]
36
+ for line in file_lines:
37
+ offsets.append(offsets[-1] + len(line))
38
+
39
+ first = normalized_search[0]
40
+ n = len(normalized_search)
41
+ spans = []
42
+ for i, file_line in enumerate(normalized_file):
43
+ if file_line != first:
44
+ continue
45
+ if normalized_file[i:i + n] == normalized_search:
46
+ spans.append((offsets[i], offsets[i + n]))
47
+ return spans
48
+
49
+
50
+ def _build_whitespace_insensitive_pattern(search_text):
51
+ parts = []
52
+ i = 0
53
+ while i < len(search_text):
54
+ ch = search_text[i]
55
+ if ch.isspace():
56
+ while i < len(search_text) and search_text[i].isspace():
57
+ i += 1
58
+ parts.append(r"\s+")
59
+ continue
60
+ parts.append(re.escape(ch))
61
+ i += 1
62
+ return re.compile("".join(parts))
63
+
64
+
65
+ def _build_fully_whitespace_agnostic_pattern(search_text):
66
+ parts = []
67
+ for i, ch in enumerate(search_text):
68
+ if ch.isspace():
69
+ continue
70
+ parts.append(re.escape(ch))
71
+ if i != len(search_text) - 1:
72
+ parts.append(r"\s*")
73
+ return re.compile("".join(parts))
74
+
75
+
76
+ def _find_unique_span_with_fallbacks(content, search_text):
77
+ count = content.count(search_text)
78
+ if count == 1:
79
+ start = content.index(search_text)
80
+ return (start, start + len(search_text))
81
+ if count > 1:
82
+ raise FileEditError(
83
+ f"Search text appears {count} times in file (must be unique)",
84
+ details={"count": count, "hint": "Add more surrounding context to make it unique"}
85
+ )
86
+
87
+ if "\n" in search_text or "\r" in search_text:
88
+ spans = _find_spans_by_line_normalization(
89
+ content, search_text, collapse_whitespace=False
90
+ )
91
+ if len(spans) == 1:
92
+ return spans[0]
93
+ if len(spans) > 1:
94
+ raise FileEditError(
95
+ f"Search text appears {len(spans)} times in file (must be unique)",
96
+ details={"count": len(spans), "hint": "Add more surrounding context to make it unique"}
97
+ )
98
+
99
+ spans = _find_spans_by_line_normalization(
100
+ content, search_text, collapse_whitespace=True
101
+ )
102
+ if len(spans) == 1:
103
+ return spans[0]
104
+ if len(spans) > 1:
105
+ raise FileEditError(
106
+ f"Search text appears {len(spans)} times in file (must be unique)",
107
+ details={"count": len(spans), "hint": "Add more surrounding context to make it unique"}
108
+ )
109
+
110
+ pattern = _build_whitespace_insensitive_pattern(search_text)
111
+ matches = list(pattern.finditer(content))
112
+ if len(matches) == 1:
113
+ return matches[0].span()
114
+ if len(matches) > 1:
115
+ raise FileEditError(
116
+ f"Search text appears {len(matches)} times in file (must be unique)",
117
+ details={"count": len(matches), "hint": "Add more surrounding context to make it unique"}
118
+ )
119
+
120
+ if not any(ch.isspace() for ch in search_text):
121
+ pattern = _build_fully_whitespace_agnostic_pattern(search_text)
122
+ matches = list(pattern.finditer(content))
123
+ if len(matches) == 1:
124
+ return matches[0].span()
125
+ if len(matches) > 1:
126
+ raise FileEditError(
127
+ f"Search text appears {len(matches)} times in file (must be unique)",
128
+ details={"count": len(matches), "hint": "Add more surrounding context to make it unique"}
129
+ )
130
+ return None
131
+
132
+
133
+ def _resolve_repo_path(path_str, repo_root, gitignore_spec=None):
134
+ """Resolve and validate a path for editing.
135
+
136
+ Args:
137
+ path_str: Path string to resolve
138
+ repo_root: Repository root directory
139
+ gitignore_spec: Optional pathspec.PathSpec for .gitignore filtering
140
+
141
+ Returns:
142
+ Resolved Path object
143
+
144
+ Raises:
145
+ PathValidationError: If path is invalid or blocked by .gitignore
146
+ """
147
+ raw_path = Path(path_str)
148
+ if not raw_path.is_absolute():
149
+ raw_path = repo_root / raw_path
150
+ resolved = raw_path.resolve()
151
+
152
+ # Check .gitignore (only applies to paths within repo)
153
+ if gitignore_spec is not None:
154
+ from utils.gitignore_filter import is_path_ignored, format_gitignore_error
155
+
156
+ is_ignored, matched_pattern = is_path_ignored(
157
+ resolved, repo_root, gitignore_spec
158
+ )
159
+ if is_ignored:
160
+ # Create descriptive error
161
+ error_msg = format_gitignore_error(resolved, repo_root, matched_pattern)
162
+ raise PathValidationError(
163
+ f"Path blocked by .gitignore: {error_msg}",
164
+ details={"path": str(resolved), "pattern": matched_pattern}
165
+ )
166
+
167
+ return resolved
168
+
169
+
170
+ def _prepare_edit(arguments, repo_root, gitignore_spec=None):
171
+ """Prepare edit operation with validation.
172
+
173
+ Args:
174
+ arguments: Edit arguments dict
175
+ repo_root: Repository root
176
+ gitignore_spec: Optional PathSpec for .gitignore filtering
177
+
178
+ Returns:
179
+ Tuple of (status_string, payload_dict or None)
180
+
181
+ Raises:
182
+ PathValidationError: If path is invalid or blocked by .gitignore
183
+ FileEditError: If file cannot be read or edit is invalid
184
+ """
185
+ path = arguments.get("path")
186
+ if not path or not isinstance(path, str) or not path.strip():
187
+ raise FileEditError("Missing or invalid 'path' parameter")
188
+
189
+ # Use updated _resolve_repo_path with gitignore checking
190
+ try:
191
+ file_path = _resolve_repo_path(path, repo_root, gitignore_spec)
192
+ except PathValidationError as e:
193
+ # Re-raise with additional context
194
+ raise FileEditError(str(e), details=e.details)
195
+
196
+ if not file_path.exists():
197
+ raise FileEditError(
198
+ f"File not found",
199
+ details={"path": str(file_path)}
200
+ )
201
+
202
+ search = arguments.get("search")
203
+ replace = arguments.get("replace")
204
+
205
+ if search is None:
206
+ raise FileEditError("'search' parameter is required")
207
+ if replace is None:
208
+ raise FileEditError("'replace' parameter is required")
209
+ if not isinstance(search, str):
210
+ raise FileEditError("'search' must be a string")
211
+ if not isinstance(replace, str):
212
+ raise FileEditError("'replace' must be a string")
213
+ if search == "":
214
+ raise FileEditError("'search' must be non-empty")
215
+
216
+ try:
217
+ with file_path.open("r", encoding="utf-8", newline="") as f:
218
+ original_content = f.read()
219
+ except Exception as e:
220
+ raise FileEditError(
221
+ f"Failed to read file",
222
+ details={"path": str(file_path), "original_error": str(e)}
223
+ )
224
+
225
+ file_newline = _detect_newline(original_content)
226
+ search, replace, _ = _normalize_search_replace_for_newlines(
227
+ search, replace, file_newline
228
+ )
229
+
230
+ search_span = _find_unique_span_with_fallbacks(original_content, search)
231
+ if search_span is None:
232
+ search_preview = search[:200] + "..." if len(search) > 200 else search
233
+ raise FileEditError(
234
+ "Search text not found in file",
235
+ details={
236
+ "search_preview": search_preview,
237
+ "hint": "Try adding more surrounding context (including nearby lines) to disambiguate whitespace/indentation differences."
238
+ }
239
+ )
240
+
241
+ context_lines = arguments.get("context_lines", 3)
242
+ if not isinstance(context_lines, int) or context_lines < 0:
243
+ context_lines = 3
244
+
245
+ color_mode = arguments.get("color", "auto")
246
+ if color_mode not in ("auto", "on", "off"):
247
+ color_mode = "auto"
248
+
249
+ return "exit_code=0", {
250
+ "file_path": file_path,
251
+ "original_content": original_content, "search_span": search_span,
252
+ "replace": replace,
253
+ "context_lines": context_lines,
254
+ "color_mode": color_mode,
255
+ }
256
+
257
+
258
+ def preview_edit_file(arguments, repo_root, gitignore_spec=None):
259
+ """Build a line-numbered diff preview without writing changes.
260
+
261
+ Returns:
262
+ Tuple of (status_string, diff_text or None)
263
+
264
+ Raises:
265
+ FileEditError: If edit validation fails
266
+ """
267
+ status, payload = _prepare_edit(arguments, repo_root, gitignore_spec)
268
+
269
+ start, end = payload["search_span"]
270
+ new_content = (
271
+ payload["original_content"][:start]
272
+ + payload["replace"]
273
+ + payload["original_content"][end:]
274
+ )
275
+ diff_text = _build_diff(
276
+ payload["original_content"],
277
+ new_content,
278
+ payload["file_path"],
279
+ payload["context_lines"],
280
+ "on",
281
+ show_header=True,
282
+ repo_root=repo_root,
283
+ )
284
+ return "exit_code=0", diff_text
285
+
286
+
287
+ def run_edit_file(arguments, repo_root, console, debug_mode, gitignore_spec=None):
288
+ """Apply search/replace edit to a file.
289
+
290
+ Args:
291
+ arguments: {
292
+ "path": "path/to/file",
293
+ "search": "exact text to find (required)",
294
+ "replace": "replacement text (required)",
295
+ "context_lines": 3 # optional, for diff display
296
+ }
297
+ repo_root: Repository root
298
+ console: Rich console for output
299
+ debug_mode: Whether to show debug output
300
+ gitignore_spec: Optional PathSpec for .gitignore filtering
301
+
302
+ Returns:
303
+ str: Formatted result with exit_code=0 or exit_code=1
304
+ """
305
+ try:
306
+ status, payload = _prepare_edit(arguments, repo_root, gitignore_spec)
307
+
308
+ start, end = payload["search_span"]
309
+ new_content = (
310
+ payload["original_content"][:start]
311
+ + payload["replace"]
312
+ + payload["original_content"][end:]
313
+ )
314
+
315
+ # Generate diff for preview
316
+ diff_text = _build_diff(
317
+ payload["original_content"],
318
+ new_content,
319
+ payload["file_path"],
320
+ payload["context_lines"],
321
+ payload["color_mode"],
322
+ )
323
+
324
+ # Write to file
325
+ try:
326
+ with payload["file_path"].open("w", encoding="utf-8", newline="") as f:
327
+ f.write(new_content)
328
+ except Exception as e:
329
+ raise FileEditError(
330
+ f"Failed to write file",
331
+ details={"path": str(payload["file_path"]), "original_error": str(e)}
332
+ )
333
+
334
+ # Success
335
+ return f"exit_code=0\n\nDiff:\n{diff_text}\n\n"
336
+
337
+ except FileEditError as e:
338
+ # Return formatted error string for backward compatibility
339
+ error_msg = str(e)
340
+ if e.details:
341
+ details_str = "\n".join(f" {k}: {v}" for k, v in e.details.items())
342
+ return f"exit_code=1\n{error_msg}\n{details_str}\n\n"
343
+ return f"exit_code=1\n{error_msg}\n\n"
344
+ except Exception as exc:
345
+ return f"exit_code=1\n{exc}\n\n"
@@ -0,0 +1,109 @@
1
+ """Shared utilities for file operations."""
2
+
3
+ import os
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple
7
+
8
+ from utils.gitignore_filter import ALWAYS_ALLOWED_FILES
9
+
10
+ # Fast-path patterns that should never be checked against spec
11
+ FAST_IGNORE_DIRS = {".git", ".venv", "__pycache__", "node_modules", "venv", "env", ".env"}
12
+ # Keep this list to high-signal "noise" files only; avoid blocking common lockfiles that may be relevant.
13
+ FAST_IGNORE_FILES = {".DS_Store", "Thumbs.db"}
14
+
15
+ _GITIGNORE_SPEC_REGISTRY = {}
16
+
17
+
18
+ def _is_fast_ignored(path: Path) -> bool:
19
+ """Quick check for common ignore patterns without spec lookup.
20
+
21
+ Args:
22
+ path: Path to check
23
+
24
+ Returns:
25
+ True if path matches fast-path ignore patterns
26
+ """
27
+ if path.name in ALWAYS_ALLOWED_FILES:
28
+ return False
29
+ if path.name in FAST_IGNORE_FILES:
30
+ return True
31
+ if any(part in FAST_IGNORE_DIRS for part in path.parts):
32
+ return True
33
+ return False
34
+
35
+
36
+ def _register_gitignore_spec(gitignore_spec) -> int:
37
+ """Register a PathSpec for cached lookups and return its key.
38
+
39
+ Args:
40
+ gitignore_spec: PathSpec object to register
41
+
42
+ Returns:
43
+ Registry key for the PathSpec object
44
+ """
45
+ if gitignore_spec is None:
46
+ return 0
47
+ key = id(gitignore_spec)
48
+ _GITIGNORE_SPEC_REGISTRY[key] = gitignore_spec
49
+ return key
50
+
51
+
52
+ @lru_cache(maxsize=1000)
53
+ def _is_ignored_cached(path_str: str, repo_root_str: str, spec_key: int) -> bool:
54
+ """Cached version of gitignore check.
55
+
56
+ Args:
57
+ path_str: String representation of path to check
58
+ repo_root_str: String representation of repository root
59
+ spec_key: Registry key for the PathSpec object
60
+
61
+ Returns:
62
+ True if path is ignored by gitignore spec
63
+ """
64
+ gitignore_spec = _GITIGNORE_SPEC_REGISTRY.get(spec_key)
65
+ if gitignore_spec is None:
66
+ return False
67
+
68
+ from utils.gitignore_filter import is_path_ignored
69
+
70
+ path = Path(path_str)
71
+ repo_root = Path(repo_root_str)
72
+ is_ignored, _ = is_path_ignored(path, repo_root, gitignore_spec)
73
+ return is_ignored
74
+
75
+
76
+ def _is_reserved_windows_name(name: str) -> bool:
77
+ """Check if filename is a reserved Windows device name.
78
+
79
+ Args:
80
+ name: Filename to check (without path)
81
+
82
+ Returns:
83
+ True if name is reserved (e.g., CON, PRN, NUL)
84
+ """
85
+ if not name:
86
+ return False
87
+ base = name.upper().split('.')[0]
88
+ return base in {
89
+ 'CON', 'PRN', 'AUX', 'NUL',
90
+ 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
91
+ 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
92
+ }
93
+
94
+
95
+ def validate_path_within_repo(path: Path, repo_root: Path) -> Tuple[bool, Optional[str]]:
96
+ """Validate that a resolved path is within the repository root.
97
+
98
+ NOTE: This check is DISABLED - native tools now work anywhere on the filesystem.
99
+ Gitignore filtering still applies for paths within the repo.
100
+
101
+ Args:
102
+ path: Resolved path to validate
103
+ repo_root: Repository root directory
104
+
105
+ Returns:
106
+ (is_valid, error_message) - error_message is None if valid
107
+ """
108
+ # Repo restriction removed - tools now work anywhere on the filesystem
109
+ return True, None