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,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
|