docsif 0.1.0__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.
Files changed (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. sif/utils/text.py +128 -0
@@ -0,0 +1,200 @@
1
+ """Document retrieval commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from sif.cli.formatters import prepend_line_numbers
11
+ from sif.core.models import Document
12
+ from sif.database.database import Database
13
+ from sif.database.repositories import DocumentRepository
14
+
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.group("get")
20
+ def get_group() -> None:
21
+ """Retrieve documents."""
22
+ pass
23
+
24
+
25
+ @get_group.command("get")
26
+ @click.argument("path_or_docid")
27
+ @click.option("--from-line", "-f", type=int, help="Start from line number")
28
+ @click.option("--lines", "-l", type=int, help="Number of lines to show")
29
+ @click.option("--line-numbers", is_flag=True, help="Show line numbers")
30
+ @click.pass_context
31
+ def get_cmd( # noqa: C901, PLR0912
32
+ ctx: click.Context,
33
+ path_or_docid: str,
34
+ from_line: int | None,
35
+ lines: int | None,
36
+ line_numbers: bool,
37
+ ) -> None:
38
+ """Get a document by path or document ID."""
39
+ index_path = ctx.obj["index_path"]
40
+
41
+ if not index_path.exists():
42
+ console.print("[yellow]No index found.[/yellow]")
43
+ return
44
+
45
+ db = Database(index_path)
46
+ db.init_schema()
47
+
48
+ with db.connection:
49
+ doc_repo = DocumentRepository(db.connection)
50
+
51
+ # Try to find by ID first
52
+ doc = doc_repo.get_by_id(path_or_docid)
53
+
54
+ if not doc:
55
+ # Try to find by path
56
+ # Need collection ID - search all collections
57
+ from sif.database.repositories import CollectionRepository # noqa: PLC0415
58
+
59
+ coll_repo = CollectionRepository(db.connection)
60
+
61
+ for coll in coll_repo.list_all():
62
+ doc = doc_repo.get_by_path(path_or_docid, coll.id)
63
+ if doc:
64
+ break
65
+
66
+ if not doc:
67
+ # Try matching by filename (short name lookup)
68
+ doc = doc_repo.get_by_filename(path_or_docid)
69
+
70
+ if not doc:
71
+ # Try as file path
72
+ path = Path(path_or_docid)
73
+ if path.exists():
74
+ try:
75
+ content = path.read_text()
76
+ console.print(f"[bold]{path}[/bold]")
77
+ console.print(content)
78
+ return
79
+ except Exception as e:
80
+ raise click.ClickException(f"Cannot read file: {e}") from e
81
+
82
+ raise click.ClickException(f"Document not found: {path_or_docid}")
83
+
84
+ # Display document
85
+ console.print(f"[bold cyan]{doc.title or doc.filename}[/bold cyan]")
86
+ console.print(f"[dim]Path: {doc.path}[/dim]")
87
+ console.print(f"[dim]Collection: {doc.collection_id}[/dim]")
88
+ console.print("")
89
+
90
+ # Get content
91
+ content = doc.content
92
+
93
+ # Apply line filtering
94
+ if from_line is not None or lines is not None:
95
+ content_lines = content.split("\n")
96
+
97
+ start = (from_line or 1) - 1 # Convert to 0-indexed
98
+ start = max(0, start)
99
+
100
+ if lines is not None:
101
+ end = start + lines
102
+ content_lines = content_lines[start:end]
103
+ else:
104
+ content_lines = content_lines[start:]
105
+
106
+ content = "\n".join(content_lines)
107
+
108
+ if line_numbers:
109
+ content = prepend_line_numbers(content)
110
+
111
+ # Display with syntax highlighting if markdown
112
+ if doc.path.endswith(".md"):
113
+ console.print(content)
114
+ else:
115
+ console.print(content)
116
+
117
+
118
+ @get_group.command("multi-get")
119
+ @click.argument("pattern")
120
+ @click.option("--max-bytes", "-b", type=int, default=100000, help="Max bytes per file")
121
+ @click.option("--line-numbers", is_flag=True, help="Show line numbers")
122
+ @click.pass_context
123
+ def multi_get_cmd( # noqa: C901
124
+ ctx: click.Context,
125
+ pattern: str,
126
+ max_bytes: int,
127
+ line_numbers: bool,
128
+ ) -> None:
129
+ """Get multiple documents matching a pattern."""
130
+ index_path = ctx.obj["index_path"]
131
+
132
+ if not index_path.exists():
133
+ console.print("[yellow]No index found.[/yellow]")
134
+ return
135
+
136
+ import fnmatch # noqa: PLC0415
137
+
138
+ db = Database(index_path)
139
+ db.init_schema()
140
+
141
+ with db.connection:
142
+ doc_repo = DocumentRepository(db.connection)
143
+ from sif.database.repositories import CollectionRepository # noqa: PLC0415
144
+
145
+ coll_repo = CollectionRepository(db.connection)
146
+
147
+ matched_docs: list[Document] = []
148
+
149
+ def _lookup_by_id_or_path_or_filename(item: str) -> Document | None:
150
+ """Try ID, then exact path, then filename match."""
151
+ doc = doc_repo.get_by_id(item)
152
+ if doc:
153
+ return doc
154
+ for coll in coll_repo.list_all():
155
+ doc = doc_repo.get_by_path(item, coll.id)
156
+ if doc:
157
+ return doc
158
+ return doc_repo.get_by_filename(item)
159
+
160
+ if "," in pattern:
161
+ # Comma-separated IDs/paths
162
+ items = [item.strip() for item in pattern.split(",") if item.strip()]
163
+ seen_ids: set[str] = set()
164
+ for item in items:
165
+ doc = _lookup_by_id_or_path_or_filename(item)
166
+ if doc and doc.id not in seen_ids:
167
+ matched_docs.append(doc)
168
+ seen_ids.add(doc.id)
169
+ elif "*" in pattern or "?" in pattern:
170
+ # Glob pattern
171
+ for coll in coll_repo.list_all():
172
+ matched_docs.extend(
173
+ doc
174
+ for doc in doc_repo.list_by_collection(coll.id)
175
+ if fnmatch.fnmatch(doc.path, pattern) or fnmatch.fnmatch(doc.filename, pattern)
176
+ )
177
+ else:
178
+ # Single item
179
+ doc = _lookup_by_id_or_path_or_filename(pattern)
180
+ if doc:
181
+ matched_docs.append(doc)
182
+
183
+ if not matched_docs:
184
+ console.print(f"[yellow]No documents matching pattern: {pattern}[/yellow]")
185
+ return
186
+
187
+ console.print(f"[green]Found {len(matched_docs)} matching document(s)[/green]")
188
+ console.print("")
189
+
190
+ for doc in matched_docs:
191
+ content = doc.content
192
+ if len(content.encode()) > max_bytes:
193
+ content = content[:max_bytes] + "\n... [truncated]"
194
+
195
+ if line_numbers:
196
+ content = prepend_line_numbers(content)
197
+
198
+ console.print(f"[bold cyan]=== {doc.path} ===[/bold cyan]")
199
+ console.print(content)
200
+ console.print("")
@@ -0,0 +1,294 @@
1
+ """Index management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ from sif.core.models import Document
13
+ from sif.database.database import Database
14
+ from sif.database.repositories import (
15
+ CollectionRepository,
16
+ DocumentChunkRepository,
17
+ DocumentRepository,
18
+ )
19
+ from sif.indexing.chunker import create_chunker
20
+ from sif.indexing.parser import MarkdownParser
21
+ from sif.indexing.scanner import FileScanner
22
+
23
+
24
+ console = Console()
25
+
26
+
27
+ @click.group("index")
28
+ def index_group() -> None:
29
+ """Index management commands."""
30
+ pass
31
+
32
+
33
+ @index_group.command("update")
34
+ @click.option("--collection", "-c", help="Update specific collection only")
35
+ @click.option("--force", "-f", is_flag=True, help="Force re-index all documents")
36
+ @click.pass_context
37
+ def update_cmd(ctx: click.Context, collection: str | None, force: bool) -> None: # noqa: C901, PLR0912, PLR0915
38
+ """Update the index by scanning collections."""
39
+ index_path = ctx.obj["index_path"]
40
+
41
+ db = Database(index_path)
42
+ db.init_schema()
43
+
44
+ with db.connection:
45
+ coll_repo = CollectionRepository(db.connection)
46
+ doc_repo = DocumentRepository(db.connection)
47
+
48
+ # Get collections to update
49
+ if collection:
50
+ collections = [coll_repo.get_by_name(collection)]
51
+ if not collections[0]:
52
+ raise click.ClickException(f"Collection '{collection}' not found")
53
+ else:
54
+ collections = coll_repo.list_all()
55
+
56
+ if not collections:
57
+ console.print(
58
+ "[yellow]No collections found. Add one with 'sif collection add'.[/yellow]"
59
+ )
60
+ return
61
+
62
+ total_added = 0
63
+ total_updated = 0
64
+ total_removed = 0
65
+
66
+ for coll in collections:
67
+ if not coll:
68
+ continue
69
+
70
+ console.print(f"\n[bold]Updating collection: {coll.name}[/bold]")
71
+
72
+ if coll.pre_update_cmd:
73
+ console.print(f" Running pre-update command: {coll.pre_update_cmd}")
74
+ result = subprocess.run(
75
+ coll.pre_update_cmd,
76
+ shell=True,
77
+ capture_output=True,
78
+ text=True,
79
+ check=False,
80
+ )
81
+ if result.returncode != 0:
82
+ err_msg = (
83
+ f"Pre-update command failed for '{coll.name}' "
84
+ f"(exit {result.returncode}): "
85
+ f"{result.stderr.strip() or result.stdout.strip()}"
86
+ )
87
+ raise click.ClickException(err_msg)
88
+
89
+ # Scan files
90
+ scanner = FileScanner()
91
+ scan_result = scanner.scan(
92
+ Path(coll.path),
93
+ pattern=coll.pattern,
94
+ ignore_patterns=coll.ignore_patterns,
95
+ )
96
+
97
+ console.print(f" Found {scan_result.file_count} files")
98
+
99
+ # Get existing documents
100
+ existing_docs = {d.path: d for d in doc_repo.list_by_collection(coll.id)}
101
+ scanned_paths = set()
102
+
103
+ # Process each file
104
+ parser = MarkdownParser()
105
+
106
+ with Progress(
107
+ SpinnerColumn(),
108
+ TextColumn("[progress.description]{task.description}"),
109
+ console=console,
110
+ ) as progress:
111
+ task = progress.add_task(f"Indexing {coll.name}...", total=scan_result.file_count)
112
+
113
+ for file_path in scan_result.files:
114
+ scanned_paths.add(str(file_path))
115
+
116
+ # Parse document
117
+ try:
118
+ parsed = parser.parse(file_path)
119
+ except Exception as e:
120
+ console.print(f" [red]Error parsing {file_path}: {e}[/red]")
121
+ progress.advance(task)
122
+ continue
123
+
124
+ # Check if document exists
125
+ existing = existing_docs.get(str(file_path))
126
+
127
+ if existing:
128
+ # Check if changed
129
+ if existing.checksum == parsed.checksum and not force:
130
+ console.print(f" [dim]Unchanged: {file_path.name}[/dim]")
131
+ progress.advance(task)
132
+ continue
133
+
134
+ # Update document
135
+ existing.content = parsed.content
136
+ existing.title = parsed.title
137
+ existing.metadata = parsed.metadata
138
+ existing.mtime = parsed.mtime
139
+ doc_repo.update(existing)
140
+ total_updated += 1
141
+ else:
142
+ # Create new document
143
+ doc = Document(
144
+ path=str(file_path),
145
+ collection_id=coll.id,
146
+ content=parsed.content,
147
+ title=parsed.title,
148
+ metadata=parsed.metadata,
149
+ mtime=parsed.mtime,
150
+ )
151
+ doc_repo.create(doc)
152
+ total_added += 1
153
+
154
+ progress.advance(task)
155
+
156
+ # Remove documents that no longer exist
157
+ for path, doc in existing_docs.items():
158
+ if path not in scanned_paths:
159
+ doc_repo.delete(doc.id)
160
+ total_removed += 1
161
+
162
+ # Update collection stats
163
+ coll.document_count = len(doc_repo.list_by_collection(coll.id))
164
+ from datetime import datetime, timezone # noqa: PLC0415
165
+
166
+ coll.last_indexed_at = datetime.now(timezone.utc)
167
+ coll_repo.update(coll)
168
+
169
+ console.print("\n[green]Index updated:[/green]")
170
+ console.print(f" Added: {total_added}")
171
+ console.print(f" Updated: {total_updated}")
172
+ console.print(f" Removed: {total_removed}")
173
+
174
+
175
+ @index_group.command("embed")
176
+ @click.option("--collection", "-c", help="Embed specific collection only")
177
+ @click.option("--force", "-f", is_flag=True, help="Force re-embed all documents")
178
+ @click.option("--chunk-strategy", default="auto", help="Chunking strategy")
179
+ @click.option("--model", "-m", help="Embedding model name")
180
+ @click.option(
181
+ "--model-type",
182
+ type=click.Choice(["sentence_transformers", "gguf", "openai", "modelscope"]),
183
+ help="Embedding model type override",
184
+ )
185
+ @click.pass_context
186
+ def embed_cmd( # noqa: C901, PLR0912, PLR0913, PLR0915
187
+ ctx: click.Context,
188
+ collection: str | None,
189
+ force: bool, # noqa: ARG001
190
+ chunk_strategy: str,
191
+ model: str | None,
192
+ model_type: str | None = None,
193
+ ) -> None:
194
+ """Generate embeddings for documents."""
195
+ index_path = ctx.obj["index_path"]
196
+
197
+ db = Database(index_path)
198
+ db.init_schema()
199
+
200
+ # Initialize embedder
201
+ from sif.config.settings import get_settings # noqa: PLC0415
202
+ from sif.embedding.manager import EmbeddingManager # noqa: PLC0415
203
+ from sif.search.vector import VectorSearcher # noqa: PLC0415
204
+
205
+ settings = get_settings()
206
+ if model:
207
+ settings = settings.model_copy(update={"model_name": model})
208
+ if model_type:
209
+ settings = settings.model_copy(update={"model_type": model_type})
210
+
211
+ try:
212
+ manager = EmbeddingManager.from_settings(settings)
213
+ except ImportError as e:
214
+ console.print(f"[red]Embedding backend not installed: {e}[/red]")
215
+ return
216
+ except Exception as e:
217
+ console.print(f"[red]Failed to load embedding model: {e}[/red]")
218
+ return
219
+
220
+ with db.connection:
221
+ coll_repo = CollectionRepository(db.connection)
222
+ doc_repo = DocumentRepository(db.connection)
223
+ chunk_repo = DocumentChunkRepository(db.connection)
224
+
225
+ # Get collections
226
+ if collection:
227
+ collections = [coll_repo.get_by_name(collection)]
228
+ if not collections[0]:
229
+ raise click.ClickException(f"Collection '{collection}' not found")
230
+ else:
231
+ collections = coll_repo.list_all()
232
+
233
+ total_chunks = 0
234
+
235
+ for coll in collections:
236
+ if not coll:
237
+ continue
238
+
239
+ console.print(f"\n[bold]Embedding collection: {coll.name}[/bold]")
240
+ documents = doc_repo.list_by_collection(coll.id)
241
+ chunker = create_chunker(chunk_strategy)
242
+
243
+ # Collect all chunks across documents
244
+ all_chunks = [] # (chunk, document_id)
245
+ doc_chunks_map: dict[str, list] = {}
246
+
247
+ for doc in documents:
248
+ chunk_repo.delete_by_document(doc.id)
249
+ chunks = chunker.chunk(doc.content)
250
+ doc_chunks_map[doc.id] = chunks
251
+ for i, chunk in enumerate(chunks):
252
+ chunk.document_id = doc.id
253
+ chunk.sequence = i
254
+ all_chunks.append((chunk, doc.id))
255
+
256
+ # Batch embed all chunks
257
+ if all_chunks:
258
+ try:
259
+ chunk_texts = [c.content for c, _ in all_chunks]
260
+ embedding_response = manager.embed(chunk_texts)
261
+ embeddings = embedding_response.embeddings
262
+
263
+ # Persist chunks and embeddings
264
+ batch_items: list[tuple[str, str, str | None, list[float]]] = []
265
+ for (chunk, doc_id), embedding in zip(all_chunks, embeddings):
266
+ chunk_repo.create(chunk)
267
+ batch_items.append((chunk.id, doc_id, chunk.id, embedding))
268
+
269
+ if batch_items:
270
+ vector_searcher = VectorSearcher(
271
+ db.connection, len(manager.embed_single("probe"))
272
+ )
273
+ vector_searcher.add_embeddings_batch(batch_items)
274
+
275
+ total_chunks += len(all_chunks)
276
+ except Exception as e:
277
+ console.print(f" [red]Error embedding collection {coll.name}: {e}[/red]")
278
+
279
+ # Update collection stats
280
+ documents = doc_repo.list_by_collection(coll.id)
281
+ coll.chunk_count = sum(len(chunk_repo.get_by_document(d.id)) for d in documents)
282
+ coll_repo.update(coll)
283
+
284
+ console.print(f"\n[green]Embedding complete: {total_chunks} chunks embedded[/green]")
285
+
286
+
287
+ @index_group.command("status")
288
+ @click.pass_context
289
+ def index_status_cmd(ctx: click.Context) -> None:
290
+ """Show index status."""
291
+ # Delegate to main status command
292
+ from sif.cli.main import status_cmd # noqa: PLC0415
293
+
294
+ ctx.invoke(status_cmd)
sif/cli/commands/ls.py ADDED
@@ -0,0 +1,67 @@
1
+ """Top-level ls command for indexed documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.tree import Tree
8
+
9
+ from sif.database.database import Database
10
+ from sif.database.repositories import CollectionRepository, DocumentRepository
11
+
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.command("ls")
17
+ @click.argument("collection", required=False)
18
+ @click.argument("subpath", required=False)
19
+ @click.pass_context
20
+ def ls_cmd(
21
+ ctx: click.Context,
22
+ collection: str | None,
23
+ subpath: str | None,
24
+ ) -> None:
25
+ """List indexed documents as a virtual file tree."""
26
+ index_path = ctx.obj["index_path"]
27
+
28
+ if not index_path.exists():
29
+ console.print("[yellow]No index found.[/yellow]")
30
+ return
31
+
32
+ db = Database(index_path)
33
+ db.init_schema()
34
+
35
+ with db.connection:
36
+ doc_repo = DocumentRepository(db.connection)
37
+ coll_repo = CollectionRepository(db.connection)
38
+
39
+ if collection:
40
+ collections = [coll_repo.get_by_name(collection)]
41
+ if not collections[0]:
42
+ raise click.ClickException(f"Collection '{collection}' not found")
43
+ else:
44
+ collections = coll_repo.list_all()
45
+
46
+ for coll in collections:
47
+ if not coll:
48
+ continue
49
+
50
+ docs = doc_repo.list_by_collection(coll.id)
51
+ if subpath:
52
+ prefix = subpath.strip("/")
53
+ docs = [d for d in docs if d.path.strip("/").startswith(prefix)]
54
+
55
+ if not docs:
56
+ continue
57
+
58
+ tree = Tree(f"[bold cyan]{coll.name}[/bold cyan]")
59
+ for doc in sorted(docs, key=lambda d: d.path):
60
+ parts = doc.path.strip("/").split("/")
61
+ node = tree
62
+ for part in parts[:-1]:
63
+ found = next((c for c in node.children if str(c.label) == part), None)
64
+ node = found if found else node.add(part)
65
+ node.add(parts[-1])
66
+
67
+ console.print(tree)
@@ -0,0 +1,93 @@
1
+ """MCP server commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+
12
+ console = Console()
13
+
14
+
15
+ @click.group("mcp")
16
+ def mcp_group() -> None:
17
+ """MCP server commands."""
18
+ pass
19
+
20
+
21
+ @mcp_group.command("stdio")
22
+ @click.pass_context
23
+ def mcp_stdio_cmd(ctx: click.Context) -> None:
24
+ """Run MCP server in stdio mode."""
25
+ index_path = ctx.obj["index_path"]
26
+
27
+ if not Path(index_path).exists():
28
+ console.print(
29
+ "[yellow]Warning: No index found. Some features may not work.[/yellow]", file=sys.stderr
30
+ )
31
+
32
+ try:
33
+ from sif.mcp.server import run_stdio_server # noqa: PLC0415
34
+
35
+ run_stdio_server(str(index_path))
36
+ except ImportError as e:
37
+ console.print(f"[red]MCP server not available: {e}[/red]")
38
+ raise click.ClickException("Install with: pip install sif[mcp]") from None
39
+
40
+
41
+ @mcp_group.command("http")
42
+ @click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
43
+ @click.option("--port", "-p", type=int, default=3000, help="Port to listen on")
44
+ @click.option("--reload", is_flag=True, help="Enable auto-reload")
45
+ @click.pass_context
46
+ def mcp_http_cmd(
47
+ ctx: click.Context,
48
+ host: str,
49
+ port: int,
50
+ reload: bool,
51
+ ) -> None:
52
+ """Run MCP server in HTTP mode."""
53
+ index_path = ctx.obj["index_path"]
54
+
55
+ console.print(f"[green]Starting MCP HTTP server on {host}:{port}[/green]")
56
+
57
+ try:
58
+ from sif.mcp.server_http import run_http_server # noqa: PLC0415
59
+
60
+ run_http_server(str(index_path), host=host, port=port, reload=reload)
61
+ except ImportError as e:
62
+ console.print(f"[red]HTTP server not available: {e}[/red]")
63
+ raise click.ClickException("Install with: pip install sif[http]") from None
64
+
65
+
66
+ @mcp_group.command("daemon")
67
+ @click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
68
+ @click.option("--port", "-p", type=int, default=3000, help="Port to listen on")
69
+ @click.option("--pid-file", help="PID file path")
70
+ @click.option("--log-file", help="Log file path")
71
+ @click.option("--stop", is_flag=True, help="Stop the daemon")
72
+ @click.pass_context
73
+ def mcp_daemon_cmd( # noqa: PLR0913
74
+ ctx: click.Context,
75
+ host: str,
76
+ port: int,
77
+ pid_file: str | None, # noqa: ARG001
78
+ log_file: str | None, # noqa: ARG001
79
+ stop: bool,
80
+ ) -> None:
81
+ """Run MCP server as a daemon."""
82
+ if stop:
83
+ console.print("[yellow]Stopping daemon...[/yellow]")
84
+ # Implementation would read PID file and kill process
85
+ console.print("[green]Daemon stopped[/green]")
86
+ return
87
+
88
+ console.print("[green]Starting MCP daemon...[/green]")
89
+ console.print(f"[dim]Host: {host}:{port}[/dim]")
90
+
91
+ # In a real implementation, this would daemonize the process
92
+ # For now, just run the HTTP server
93
+ ctx.invoke(mcp_http_cmd, host=host, port=port, reload=False)