mcp-vector-search 0.0.3__py3-none-any.whl → 0.4.12__py3-none-any.whl
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.
Potentially problematic release.
This version of mcp-vector-search might be problematic. Click here for more details.
- mcp_vector_search/__init__.py +3 -2
- mcp_vector_search/cli/commands/auto_index.py +397 -0
- mcp_vector_search/cli/commands/config.py +88 -40
- mcp_vector_search/cli/commands/index.py +198 -52
- mcp_vector_search/cli/commands/init.py +471 -58
- mcp_vector_search/cli/commands/install.py +284 -0
- mcp_vector_search/cli/commands/mcp.py +495 -0
- mcp_vector_search/cli/commands/search.py +241 -87
- mcp_vector_search/cli/commands/status.py +184 -58
- mcp_vector_search/cli/commands/watch.py +34 -35
- mcp_vector_search/cli/didyoumean.py +184 -0
- mcp_vector_search/cli/export.py +320 -0
- mcp_vector_search/cli/history.py +292 -0
- mcp_vector_search/cli/interactive.py +342 -0
- mcp_vector_search/cli/main.py +175 -27
- mcp_vector_search/cli/output.py +63 -45
- mcp_vector_search/config/defaults.py +50 -36
- mcp_vector_search/config/settings.py +49 -35
- mcp_vector_search/core/auto_indexer.py +298 -0
- mcp_vector_search/core/connection_pool.py +322 -0
- mcp_vector_search/core/database.py +335 -25
- mcp_vector_search/core/embeddings.py +73 -29
- mcp_vector_search/core/exceptions.py +19 -2
- mcp_vector_search/core/factory.py +310 -0
- mcp_vector_search/core/git_hooks.py +345 -0
- mcp_vector_search/core/indexer.py +237 -73
- mcp_vector_search/core/models.py +21 -19
- mcp_vector_search/core/project.py +73 -58
- mcp_vector_search/core/scheduler.py +330 -0
- mcp_vector_search/core/search.py +574 -86
- mcp_vector_search/core/watcher.py +48 -46
- mcp_vector_search/mcp/__init__.py +4 -0
- mcp_vector_search/mcp/__main__.py +25 -0
- mcp_vector_search/mcp/server.py +701 -0
- mcp_vector_search/parsers/base.py +30 -31
- mcp_vector_search/parsers/javascript.py +74 -48
- mcp_vector_search/parsers/python.py +57 -49
- mcp_vector_search/parsers/registry.py +47 -32
- mcp_vector_search/parsers/text.py +179 -0
- mcp_vector_search/utils/__init__.py +40 -0
- mcp_vector_search/utils/gitignore.py +229 -0
- mcp_vector_search/utils/timing.py +334 -0
- mcp_vector_search/utils/version.py +47 -0
- {mcp_vector_search-0.0.3.dist-info → mcp_vector_search-0.4.12.dist-info}/METADATA +173 -7
- mcp_vector_search-0.4.12.dist-info/RECORD +54 -0
- mcp_vector_search-0.0.3.dist-info/RECORD +0 -35
- {mcp_vector_search-0.0.3.dist-info → mcp_vector_search-0.4.12.dist-info}/WHEEL +0 -0
- {mcp_vector_search-0.0.3.dist-info → mcp_vector_search-0.4.12.dist-info}/entry_points.txt +0 -0
- {mcp_vector_search-0.0.3.dist-info → mcp_vector_search-0.4.12.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import List, Optional, Set
|
|
6
5
|
|
|
7
6
|
from loguru import logger
|
|
8
7
|
|
|
@@ -14,6 +13,7 @@ from ..config.defaults import (
|
|
|
14
13
|
get_language_from_extension,
|
|
15
14
|
)
|
|
16
15
|
from ..config.settings import ProjectConfig
|
|
16
|
+
from ..utils.gitignore import create_gitignore_parser
|
|
17
17
|
from .exceptions import (
|
|
18
18
|
ConfigurationError,
|
|
19
19
|
ProjectInitializationError,
|
|
@@ -25,19 +25,26 @@ from .models import ProjectInfo
|
|
|
25
25
|
class ProjectManager:
|
|
26
26
|
"""Manages project detection, initialization, and configuration."""
|
|
27
27
|
|
|
28
|
-
def __init__(self, project_root:
|
|
28
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
29
29
|
"""Initialize project manager.
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
Args:
|
|
32
32
|
project_root: Project root directory. If None, will auto-detect.
|
|
33
33
|
"""
|
|
34
34
|
self.project_root = project_root or self._detect_project_root()
|
|
35
|
-
self._config:
|
|
35
|
+
self._config: ProjectConfig | None = None
|
|
36
|
+
|
|
37
|
+
# Initialize gitignore parser
|
|
38
|
+
try:
|
|
39
|
+
self.gitignore_parser = create_gitignore_parser(self.project_root)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.debug(f"Failed to load gitignore patterns: {e}")
|
|
42
|
+
self.gitignore_parser = None
|
|
36
43
|
|
|
37
44
|
def _detect_project_root(self) -> Path:
|
|
38
45
|
"""Auto-detect project root directory."""
|
|
39
46
|
current = Path.cwd()
|
|
40
|
-
|
|
47
|
+
|
|
41
48
|
# Look for common project indicators
|
|
42
49
|
indicators = [
|
|
43
50
|
".git",
|
|
@@ -50,14 +57,14 @@ class ProjectManager:
|
|
|
50
57
|
"build.gradle",
|
|
51
58
|
".project",
|
|
52
59
|
]
|
|
53
|
-
|
|
60
|
+
|
|
54
61
|
# Walk up the directory tree
|
|
55
62
|
for path in [current] + list(current.parents):
|
|
56
63
|
for indicator in indicators:
|
|
57
64
|
if (path / indicator).exists():
|
|
58
65
|
logger.debug(f"Detected project root: {path} (found {indicator})")
|
|
59
66
|
return path
|
|
60
|
-
|
|
67
|
+
|
|
61
68
|
# Default to current directory
|
|
62
69
|
logger.debug(f"Using current directory as project root: {current}")
|
|
63
70
|
return current
|
|
@@ -66,27 +73,27 @@ class ProjectManager:
|
|
|
66
73
|
"""Check if project is initialized for MCP Vector Search."""
|
|
67
74
|
config_path = get_default_config_path(self.project_root)
|
|
68
75
|
index_path = get_default_index_path(self.project_root)
|
|
69
|
-
|
|
76
|
+
|
|
70
77
|
return config_path.exists() and index_path.exists()
|
|
71
78
|
|
|
72
79
|
def initialize(
|
|
73
80
|
self,
|
|
74
|
-
file_extensions:
|
|
81
|
+
file_extensions: list[str] | None = None,
|
|
75
82
|
embedding_model: str = "microsoft/codebert-base",
|
|
76
|
-
similarity_threshold: float = 0.
|
|
83
|
+
similarity_threshold: float = 0.5,
|
|
77
84
|
force: bool = False,
|
|
78
85
|
) -> ProjectConfig:
|
|
79
86
|
"""Initialize project for MCP Vector Search.
|
|
80
|
-
|
|
87
|
+
|
|
81
88
|
Args:
|
|
82
89
|
file_extensions: File extensions to index
|
|
83
90
|
embedding_model: Embedding model to use
|
|
84
91
|
similarity_threshold: Similarity threshold for search
|
|
85
92
|
force: Force re-initialization if already exists
|
|
86
|
-
|
|
93
|
+
|
|
87
94
|
Returns:
|
|
88
95
|
Project configuration
|
|
89
|
-
|
|
96
|
+
|
|
90
97
|
Raises:
|
|
91
98
|
ProjectInitializationError: If initialization fails
|
|
92
99
|
"""
|
|
@@ -99,11 +106,13 @@ class ProjectManager:
|
|
|
99
106
|
# Create index directory
|
|
100
107
|
index_path = get_default_index_path(self.project_root)
|
|
101
108
|
index_path.mkdir(parents=True, exist_ok=True)
|
|
102
|
-
|
|
109
|
+
|
|
103
110
|
# Detect languages and files
|
|
104
111
|
detected_languages = self.detect_languages()
|
|
105
|
-
file_count = self.count_indexable_files(
|
|
106
|
-
|
|
112
|
+
file_count = self.count_indexable_files(
|
|
113
|
+
file_extensions or DEFAULT_FILE_EXTENSIONS
|
|
114
|
+
)
|
|
115
|
+
|
|
107
116
|
# Create configuration
|
|
108
117
|
config = ProjectConfig(
|
|
109
118
|
project_root=self.project_root,
|
|
@@ -113,29 +122,31 @@ class ProjectManager:
|
|
|
113
122
|
similarity_threshold=similarity_threshold,
|
|
114
123
|
languages=detected_languages,
|
|
115
124
|
)
|
|
116
|
-
|
|
125
|
+
|
|
117
126
|
# Save configuration
|
|
118
127
|
self.save_config(config)
|
|
119
|
-
|
|
128
|
+
|
|
120
129
|
logger.info(
|
|
121
130
|
f"Initialized project at {self.project_root}",
|
|
122
131
|
languages=detected_languages,
|
|
123
132
|
file_count=file_count,
|
|
124
133
|
extensions=config.file_extensions,
|
|
125
134
|
)
|
|
126
|
-
|
|
135
|
+
|
|
127
136
|
self._config = config
|
|
128
137
|
return config
|
|
129
|
-
|
|
138
|
+
|
|
130
139
|
except Exception as e:
|
|
131
|
-
raise ProjectInitializationError(
|
|
140
|
+
raise ProjectInitializationError(
|
|
141
|
+
f"Failed to initialize project: {e}"
|
|
142
|
+
) from e
|
|
132
143
|
|
|
133
144
|
def load_config(self) -> ProjectConfig:
|
|
134
145
|
"""Load project configuration.
|
|
135
|
-
|
|
146
|
+
|
|
136
147
|
Returns:
|
|
137
148
|
Project configuration
|
|
138
|
-
|
|
149
|
+
|
|
139
150
|
Raises:
|
|
140
151
|
ProjectNotFoundError: If project is not initialized
|
|
141
152
|
ConfigurationError: If configuration is invalid
|
|
@@ -146,45 +157,45 @@ class ProjectManager:
|
|
|
146
157
|
)
|
|
147
158
|
|
|
148
159
|
config_path = get_default_config_path(self.project_root)
|
|
149
|
-
|
|
160
|
+
|
|
150
161
|
try:
|
|
151
|
-
with open(config_path
|
|
162
|
+
with open(config_path) as f:
|
|
152
163
|
config_data = json.load(f)
|
|
153
|
-
|
|
164
|
+
|
|
154
165
|
# Convert paths back to Path objects
|
|
155
166
|
config_data["project_root"] = Path(config_data["project_root"])
|
|
156
167
|
config_data["index_path"] = Path(config_data["index_path"])
|
|
157
|
-
|
|
168
|
+
|
|
158
169
|
config = ProjectConfig(**config_data)
|
|
159
170
|
self._config = config
|
|
160
171
|
return config
|
|
161
|
-
|
|
172
|
+
|
|
162
173
|
except Exception as e:
|
|
163
174
|
raise ConfigurationError(f"Failed to load configuration: {e}") from e
|
|
164
175
|
|
|
165
176
|
def save_config(self, config: ProjectConfig) -> None:
|
|
166
177
|
"""Save project configuration.
|
|
167
|
-
|
|
178
|
+
|
|
168
179
|
Args:
|
|
169
180
|
config: Project configuration to save
|
|
170
|
-
|
|
181
|
+
|
|
171
182
|
Raises:
|
|
172
183
|
ConfigurationError: If saving fails
|
|
173
184
|
"""
|
|
174
185
|
config_path = get_default_config_path(self.project_root)
|
|
175
186
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
-
|
|
187
|
+
|
|
177
188
|
try:
|
|
178
189
|
# Convert to JSON-serializable format
|
|
179
|
-
config_data = config.
|
|
190
|
+
config_data = config.model_dump()
|
|
180
191
|
config_data["project_root"] = str(config.project_root)
|
|
181
192
|
config_data["index_path"] = str(config.index_path)
|
|
182
|
-
|
|
193
|
+
|
|
183
194
|
with open(config_path, "w") as f:
|
|
184
195
|
json.dump(config_data, f, indent=2)
|
|
185
|
-
|
|
196
|
+
|
|
186
197
|
logger.debug(f"Saved configuration to {config_path}")
|
|
187
|
-
|
|
198
|
+
|
|
188
199
|
except Exception as e:
|
|
189
200
|
raise ConfigurationError(f"Failed to save configuration: {e}") from e
|
|
190
201
|
|
|
@@ -195,27 +206,27 @@ class ProjectManager:
|
|
|
195
206
|
self._config = self.load_config()
|
|
196
207
|
return self._config
|
|
197
208
|
|
|
198
|
-
def detect_languages(self) ->
|
|
209
|
+
def detect_languages(self) -> list[str]:
|
|
199
210
|
"""Detect programming languages in the project.
|
|
200
|
-
|
|
211
|
+
|
|
201
212
|
Returns:
|
|
202
213
|
List of detected language names
|
|
203
214
|
"""
|
|
204
|
-
languages:
|
|
205
|
-
|
|
215
|
+
languages: set[str] = set()
|
|
216
|
+
|
|
206
217
|
for file_path in self._iter_source_files():
|
|
207
218
|
language = get_language_from_extension(file_path.suffix)
|
|
208
219
|
if language != "text":
|
|
209
220
|
languages.add(language)
|
|
210
|
-
|
|
211
|
-
return sorted(list(languages))
|
|
212
221
|
|
|
213
|
-
|
|
222
|
+
return sorted(languages)
|
|
223
|
+
|
|
224
|
+
def count_indexable_files(self, extensions: list[str]) -> int:
|
|
214
225
|
"""Count files that can be indexed.
|
|
215
|
-
|
|
226
|
+
|
|
216
227
|
Args:
|
|
217
228
|
extensions: File extensions to count
|
|
218
|
-
|
|
229
|
+
|
|
219
230
|
Returns:
|
|
220
231
|
Number of indexable files
|
|
221
232
|
"""
|
|
@@ -227,17 +238,17 @@ class ProjectManager:
|
|
|
227
238
|
|
|
228
239
|
def get_project_info(self) -> ProjectInfo:
|
|
229
240
|
"""Get comprehensive project information.
|
|
230
|
-
|
|
241
|
+
|
|
231
242
|
Returns:
|
|
232
243
|
Project information
|
|
233
244
|
"""
|
|
234
245
|
config_path = get_default_config_path(self.project_root)
|
|
235
246
|
index_path = get_default_index_path(self.project_root)
|
|
236
|
-
|
|
247
|
+
|
|
237
248
|
is_initialized = self.is_initialized()
|
|
238
249
|
languages = []
|
|
239
250
|
file_count = 0
|
|
240
|
-
|
|
251
|
+
|
|
241
252
|
if is_initialized:
|
|
242
253
|
try:
|
|
243
254
|
config = self.config
|
|
@@ -246,7 +257,7 @@ class ProjectManager:
|
|
|
246
257
|
except Exception:
|
|
247
258
|
# Ignore errors when getting detailed info
|
|
248
259
|
pass
|
|
249
|
-
|
|
260
|
+
|
|
250
261
|
return ProjectInfo(
|
|
251
262
|
name=self.project_root.name,
|
|
252
263
|
root_path=self.project_root,
|
|
@@ -257,40 +268,44 @@ class ProjectManager:
|
|
|
257
268
|
file_count=file_count,
|
|
258
269
|
)
|
|
259
270
|
|
|
260
|
-
def _iter_source_files(self) ->
|
|
271
|
+
def _iter_source_files(self) -> list[Path]:
|
|
261
272
|
"""Iterate over source files in the project.
|
|
262
|
-
|
|
273
|
+
|
|
263
274
|
Returns:
|
|
264
275
|
List of source file paths
|
|
265
276
|
"""
|
|
266
277
|
files = []
|
|
267
|
-
|
|
278
|
+
|
|
268
279
|
for path in self.project_root.rglob("*"):
|
|
269
280
|
if not path.is_file():
|
|
270
281
|
continue
|
|
271
|
-
|
|
282
|
+
|
|
272
283
|
# Skip ignored patterns
|
|
273
284
|
if self._should_ignore_path(path):
|
|
274
285
|
continue
|
|
275
|
-
|
|
286
|
+
|
|
276
287
|
files.append(path)
|
|
277
|
-
|
|
288
|
+
|
|
278
289
|
return files
|
|
279
290
|
|
|
280
291
|
def _should_ignore_path(self, path: Path) -> bool:
|
|
281
292
|
"""Check if a path should be ignored.
|
|
282
|
-
|
|
293
|
+
|
|
283
294
|
Args:
|
|
284
295
|
path: Path to check
|
|
285
|
-
|
|
296
|
+
|
|
286
297
|
Returns:
|
|
287
298
|
True if path should be ignored
|
|
288
299
|
"""
|
|
300
|
+
# First check gitignore rules if available
|
|
301
|
+
if self.gitignore_parser and self.gitignore_parser.is_ignored(path):
|
|
302
|
+
return True
|
|
303
|
+
|
|
289
304
|
# Check if any parent directory is in ignore patterns
|
|
290
305
|
for part in path.parts:
|
|
291
306
|
if part in DEFAULT_IGNORE_PATTERNS:
|
|
292
307
|
return True
|
|
293
|
-
|
|
308
|
+
|
|
294
309
|
# Check relative path from project root
|
|
295
310
|
try:
|
|
296
311
|
relative_path = path.relative_to(self.project_root)
|
|
@@ -300,5 +315,5 @@ class ProjectManager:
|
|
|
300
315
|
except ValueError:
|
|
301
316
|
# Path is not relative to project root
|
|
302
317
|
return True
|
|
303
|
-
|
|
318
|
+
|
|
304
319
|
return False
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Scheduling utilities for automatic reindexing."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SchedulerManager:
|
|
12
|
+
"""Manages scheduled tasks for automatic reindexing."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, project_root: Path):
|
|
15
|
+
"""Initialize scheduler manager.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
project_root: Project root directory
|
|
19
|
+
"""
|
|
20
|
+
self.project_root = project_root
|
|
21
|
+
self.system = platform.system().lower()
|
|
22
|
+
|
|
23
|
+
def install_scheduled_task(
|
|
24
|
+
self, interval_minutes: int = 60, task_name: str | None = None
|
|
25
|
+
) -> bool:
|
|
26
|
+
"""Install a scheduled task for automatic reindexing.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
interval_minutes: Interval between reindex checks in minutes
|
|
30
|
+
task_name: Custom task name (auto-generated if None)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True if task was installed successfully
|
|
34
|
+
"""
|
|
35
|
+
if task_name is None:
|
|
36
|
+
safe_path = str(self.project_root).replace("/", "_").replace("\\", "_")
|
|
37
|
+
task_name = f"mcp_vector_search_reindex_{safe_path}"
|
|
38
|
+
|
|
39
|
+
if self.system == "linux" or self.system == "darwin":
|
|
40
|
+
return self._install_cron_job(interval_minutes, task_name)
|
|
41
|
+
elif self.system == "windows":
|
|
42
|
+
return self._install_windows_task(interval_minutes, task_name)
|
|
43
|
+
else:
|
|
44
|
+
logger.error(f"Unsupported system: {self.system}")
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def uninstall_scheduled_task(self, task_name: str | None = None) -> bool:
|
|
48
|
+
"""Uninstall scheduled task.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
task_name: Task name to uninstall (auto-generated if None)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
True if task was uninstalled successfully
|
|
55
|
+
"""
|
|
56
|
+
if task_name is None:
|
|
57
|
+
safe_path = str(self.project_root).replace("/", "_").replace("\\", "_")
|
|
58
|
+
task_name = f"mcp_vector_search_reindex_{safe_path}"
|
|
59
|
+
|
|
60
|
+
if self.system == "linux" or self.system == "darwin":
|
|
61
|
+
return self._uninstall_cron_job(task_name)
|
|
62
|
+
elif self.system == "windows":
|
|
63
|
+
return self._uninstall_windows_task(task_name)
|
|
64
|
+
else:
|
|
65
|
+
logger.error(f"Unsupported system: {self.system}")
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def _install_cron_job(self, interval_minutes: int, task_name: str) -> bool:
|
|
69
|
+
"""Install cron job on Linux/macOS."""
|
|
70
|
+
try:
|
|
71
|
+
# Generate cron command
|
|
72
|
+
python_path = sys.executable
|
|
73
|
+
project_root = str(self.project_root)
|
|
74
|
+
|
|
75
|
+
# Create wrapper script
|
|
76
|
+
script_content = f'''#!/bin/bash
|
|
77
|
+
# MCP Vector Search Auto-Reindex - {task_name}
|
|
78
|
+
cd "{project_root}" || exit 1
|
|
79
|
+
|
|
80
|
+
# Check if mcp-vector-search is available
|
|
81
|
+
if command -v mcp-vector-search &> /dev/null; then
|
|
82
|
+
mcp-vector-search auto-index check --auto-reindex --max-files 10
|
|
83
|
+
elif [ -f "{python_path}" ]; then
|
|
84
|
+
"{python_path}" -m mcp_vector_search auto-index check --auto-reindex --max-files 10
|
|
85
|
+
else
|
|
86
|
+
python3 -m mcp_vector_search auto-index check --auto-reindex --max-files 10
|
|
87
|
+
fi
|
|
88
|
+
'''
|
|
89
|
+
|
|
90
|
+
# Write script to temp file
|
|
91
|
+
script_dir = Path.home() / ".mcp-vector-search" / "scripts"
|
|
92
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
script_file = script_dir / f"{task_name}.sh"
|
|
94
|
+
|
|
95
|
+
script_file.write_text(script_content)
|
|
96
|
+
script_file.chmod(0o755)
|
|
97
|
+
|
|
98
|
+
# Calculate cron schedule
|
|
99
|
+
if interval_minutes >= 60:
|
|
100
|
+
# Hourly or less frequent
|
|
101
|
+
hours = interval_minutes // 60
|
|
102
|
+
cron_schedule = f"0 */{hours} * * *"
|
|
103
|
+
else:
|
|
104
|
+
# More frequent than hourly
|
|
105
|
+
cron_schedule = f"*/{interval_minutes} * * * *"
|
|
106
|
+
|
|
107
|
+
# Add to crontab
|
|
108
|
+
cron_entry = f"{cron_schedule} {script_file} # {task_name}\n"
|
|
109
|
+
|
|
110
|
+
# Get current crontab
|
|
111
|
+
try:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["crontab", "-l"], capture_output=True, text=True, check=True
|
|
114
|
+
)
|
|
115
|
+
current_crontab = result.stdout
|
|
116
|
+
except subprocess.CalledProcessError:
|
|
117
|
+
current_crontab = ""
|
|
118
|
+
|
|
119
|
+
# Check if entry already exists
|
|
120
|
+
if task_name in current_crontab:
|
|
121
|
+
logger.info(f"Cron job {task_name} already exists")
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
# Add new entry
|
|
125
|
+
new_crontab = current_crontab + cron_entry
|
|
126
|
+
|
|
127
|
+
# Install new crontab
|
|
128
|
+
process = subprocess.Popen(
|
|
129
|
+
["crontab", "-"], stdin=subprocess.PIPE, text=True
|
|
130
|
+
)
|
|
131
|
+
process.communicate(input=new_crontab)
|
|
132
|
+
|
|
133
|
+
if process.returncode == 0:
|
|
134
|
+
logger.info(f"Installed cron job: {task_name}")
|
|
135
|
+
logger.info(f"Schedule: every {interval_minutes} minutes")
|
|
136
|
+
logger.info(f"Script: {script_file}")
|
|
137
|
+
return True
|
|
138
|
+
else:
|
|
139
|
+
logger.error("Failed to install cron job")
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"Failed to install cron job: {e}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def _uninstall_cron_job(self, task_name: str) -> bool:
|
|
147
|
+
"""Uninstall cron job on Linux/macOS."""
|
|
148
|
+
try:
|
|
149
|
+
# Get current crontab
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["crontab", "-l"], capture_output=True, text=True, check=True
|
|
153
|
+
)
|
|
154
|
+
current_crontab = result.stdout
|
|
155
|
+
except subprocess.CalledProcessError:
|
|
156
|
+
logger.info("No crontab found")
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
# Remove lines containing task name
|
|
160
|
+
lines = current_crontab.split("\n")
|
|
161
|
+
new_lines = [line for line in lines if task_name not in line]
|
|
162
|
+
new_crontab = "\n".join(new_lines)
|
|
163
|
+
|
|
164
|
+
# Install new crontab
|
|
165
|
+
if new_crontab.strip():
|
|
166
|
+
process = subprocess.Popen(
|
|
167
|
+
["crontab", "-"], stdin=subprocess.PIPE, text=True
|
|
168
|
+
)
|
|
169
|
+
process.communicate(input=new_crontab)
|
|
170
|
+
else:
|
|
171
|
+
# Remove crontab entirely if empty
|
|
172
|
+
subprocess.run(["crontab", "-r"], check=False)
|
|
173
|
+
|
|
174
|
+
# Remove script file
|
|
175
|
+
script_dir = Path.home() / ".mcp-vector-search" / "scripts"
|
|
176
|
+
script_file = script_dir / f"{task_name}.sh"
|
|
177
|
+
if script_file.exists():
|
|
178
|
+
script_file.unlink()
|
|
179
|
+
|
|
180
|
+
logger.info(f"Uninstalled cron job: {task_name}")
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to uninstall cron job: {e}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def _install_windows_task(self, interval_minutes: int, task_name: str) -> bool:
|
|
188
|
+
"""Install Windows scheduled task."""
|
|
189
|
+
try:
|
|
190
|
+
python_path = sys.executable
|
|
191
|
+
project_root = str(self.project_root)
|
|
192
|
+
|
|
193
|
+
# Create PowerShell script
|
|
194
|
+
script_content = f'''# MCP Vector Search Auto-Reindex - {task_name}
|
|
195
|
+
Set-Location "{project_root}"
|
|
196
|
+
|
|
197
|
+
try {{
|
|
198
|
+
if (Get-Command "mcp-vector-search" -ErrorAction SilentlyContinue) {{
|
|
199
|
+
mcp-vector-search auto-index check --auto-reindex --max-files 10
|
|
200
|
+
}} elseif (Test-Path "{python_path}") {{
|
|
201
|
+
& "{python_path}" -m mcp_vector_search auto-index check --auto-reindex --max-files 10
|
|
202
|
+
}} else {{
|
|
203
|
+
python -m mcp_vector_search auto-index check --auto-reindex --max-files 10
|
|
204
|
+
}}
|
|
205
|
+
}} catch {{
|
|
206
|
+
# Silently ignore errors
|
|
207
|
+
}}
|
|
208
|
+
'''
|
|
209
|
+
|
|
210
|
+
# Write script
|
|
211
|
+
script_dir = Path.home() / ".mcp-vector-search" / "scripts"
|
|
212
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
script_file = script_dir / f"{task_name}.ps1"
|
|
214
|
+
|
|
215
|
+
script_file.write_text(script_content)
|
|
216
|
+
|
|
217
|
+
# Create scheduled task using schtasks
|
|
218
|
+
cmd = [
|
|
219
|
+
"schtasks",
|
|
220
|
+
"/create",
|
|
221
|
+
"/tn",
|
|
222
|
+
task_name,
|
|
223
|
+
"/tr",
|
|
224
|
+
f'powershell.exe -ExecutionPolicy Bypass -File "{script_file}"',
|
|
225
|
+
"/sc",
|
|
226
|
+
"minute",
|
|
227
|
+
"/mo",
|
|
228
|
+
str(interval_minutes),
|
|
229
|
+
"/f", # Force overwrite if exists
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
233
|
+
|
|
234
|
+
if result.returncode == 0:
|
|
235
|
+
logger.info(f"Installed Windows task: {task_name}")
|
|
236
|
+
logger.info(f"Schedule: every {interval_minutes} minutes")
|
|
237
|
+
return True
|
|
238
|
+
else:
|
|
239
|
+
logger.error(f"Failed to install Windows task: {result.stderr}")
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(f"Failed to install Windows task: {e}")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def _uninstall_windows_task(self, task_name: str) -> bool:
|
|
247
|
+
"""Uninstall Windows scheduled task."""
|
|
248
|
+
try:
|
|
249
|
+
# Delete scheduled task
|
|
250
|
+
cmd = ["schtasks", "/delete", "/tn", task_name, "/f"]
|
|
251
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
252
|
+
|
|
253
|
+
# Remove script file
|
|
254
|
+
script_dir = Path.home() / ".mcp-vector-search" / "scripts"
|
|
255
|
+
script_file = script_dir / f"{task_name}.ps1"
|
|
256
|
+
if script_file.exists():
|
|
257
|
+
script_file.unlink()
|
|
258
|
+
|
|
259
|
+
if result.returncode == 0:
|
|
260
|
+
logger.info(f"Uninstalled Windows task: {task_name}")
|
|
261
|
+
return True
|
|
262
|
+
else:
|
|
263
|
+
# Task might not exist, which is fine
|
|
264
|
+
logger.info(
|
|
265
|
+
f"Windows task {task_name} was not found (already uninstalled)"
|
|
266
|
+
)
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"Failed to uninstall Windows task: {e}")
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def get_scheduled_task_status(self, task_name: str | None = None) -> dict:
|
|
274
|
+
"""Get status of scheduled tasks.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
task_name: Task name to check (auto-generated if None)
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dictionary with task status information
|
|
281
|
+
"""
|
|
282
|
+
if task_name is None:
|
|
283
|
+
safe_path = str(self.project_root).replace("/", "_").replace("\\", "_")
|
|
284
|
+
task_name = f"mcp_vector_search_reindex_{safe_path}"
|
|
285
|
+
|
|
286
|
+
status = {
|
|
287
|
+
"system": self.system,
|
|
288
|
+
"task_name": task_name,
|
|
289
|
+
"exists": False,
|
|
290
|
+
"enabled": False,
|
|
291
|
+
"last_run": None,
|
|
292
|
+
"next_run": None,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if self.system == "linux" or self.system == "darwin":
|
|
296
|
+
status.update(self._get_cron_status(task_name))
|
|
297
|
+
elif self.system == "windows":
|
|
298
|
+
status.update(self._get_windows_task_status(task_name))
|
|
299
|
+
|
|
300
|
+
return status
|
|
301
|
+
|
|
302
|
+
def _get_cron_status(self, task_name: str) -> dict:
|
|
303
|
+
"""Get cron job status."""
|
|
304
|
+
try:
|
|
305
|
+
result = subprocess.run(
|
|
306
|
+
["crontab", "-l"], capture_output=True, text=True, check=True
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
exists = task_name in result.stdout
|
|
310
|
+
return {"exists": exists, "enabled": exists}
|
|
311
|
+
|
|
312
|
+
except subprocess.CalledProcessError:
|
|
313
|
+
return {"exists": False, "enabled": False}
|
|
314
|
+
|
|
315
|
+
def _get_windows_task_status(self, task_name: str) -> dict:
|
|
316
|
+
"""Get Windows task status."""
|
|
317
|
+
try:
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
["schtasks", "/query", "/tn", task_name], capture_output=True, text=True
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if result.returncode == 0:
|
|
323
|
+
# Parse output for status
|
|
324
|
+
enabled = "Ready" in result.stdout or "Running" in result.stdout
|
|
325
|
+
return {"exists": True, "enabled": enabled}
|
|
326
|
+
else:
|
|
327
|
+
return {"exists": False, "enabled": False}
|
|
328
|
+
|
|
329
|
+
except Exception:
|
|
330
|
+
return {"exists": False, "enabled": False}
|