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,148 @@
1
+ """Benchmark commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from sif.config.settings import get_settings
13
+ from sif.database.database import Database
14
+ from sif.database.repositories import CollectionRepository
15
+ from sif.embedding.manager import EmbeddingManager
16
+ from sif.search.benchmark import SearchEvaluator
17
+ from sif.search.hybrid import SearchPipeline
18
+ from sif.utils.logging import get_logger
19
+
20
+
21
+ logger = get_logger(__name__)
22
+ console = Console()
23
+
24
+
25
+ @click.command("bench")
26
+ @click.argument("fixture", type=click.Path(exists=True, path_type=Path))
27
+ @click.option("-n", "--limit", default=10, help="Number of results per query")
28
+ @click.option("-C", "--candidate-limit", default=20, help="Reranker candidate pool size")
29
+ @click.option("-c", "--collection", multiple=True, help="Collection to search")
30
+ @click.option("--all", "search_all", is_flag=True, help="Search all collections")
31
+ @click.option(
32
+ "--model-type",
33
+ type=click.Choice(["sentence_transformers", "gguf", "openai", "modelscope"]),
34
+ help="Embedding model type override",
35
+ )
36
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
37
+ @click.pass_context
38
+ def bench_cmd( # noqa: C901, PLR0913, PLR0915
39
+ ctx: click.Context,
40
+ fixture: Path,
41
+ limit: int,
42
+ candidate_limit: int,
43
+ collection: tuple,
44
+ search_all: bool,
45
+ model_type: str | None,
46
+ output_json: bool,
47
+ ) -> None:
48
+ """Run benchmark evaluation against a fixture file.
49
+
50
+ Fixture format (JSON):
51
+ {
52
+ "queries": [
53
+ {
54
+ "query": "search terms",
55
+ "relevant_docids": ["doc-id-1", "doc-id-2"],
56
+ "collections": ["optional-collection-filter"]
57
+ }
58
+ ]
59
+ }
60
+ """
61
+ index_path = ctx.obj["index_path"]
62
+
63
+ if not index_path.exists():
64
+ console.print("[yellow]No index found. Run 'sif update' first.[/yellow]")
65
+ return
66
+
67
+ # Load fixture
68
+ with open(fixture) as f:
69
+ fixture_data = json.load(f)
70
+
71
+ db = Database(index_path)
72
+ db.init_schema()
73
+
74
+ # Build search function
75
+ settings = get_settings()
76
+ if model_type:
77
+ settings = settings.model_copy(update={"model_type": model_type})
78
+
79
+ # Load embedder
80
+ manager = None
81
+ embedding_dim = 384
82
+ try:
83
+ manager = EmbeddingManager.from_settings(settings)
84
+ manager.load_model()
85
+ embedding_dim = len(manager.embed_single("probe"))
86
+ except ImportError as e:
87
+ console.print(f"[yellow]Embedding backend not installed: {e}[/yellow]")
88
+ except Exception as e:
89
+ console.print(f"[yellow]Failed to load embedding model: {e}[/yellow]")
90
+
91
+ def search_fn(query: str):
92
+ from sif.core.models import SearchOptions # noqa: PLC0415
93
+
94
+ options = SearchOptions(
95
+ limit=limit,
96
+ candidate_limit=candidate_limit,
97
+ include_content=False,
98
+ include_highlights=False,
99
+ )
100
+
101
+ # Get collection IDs if specified
102
+ if collection:
103
+ with db.connection:
104
+ repo = CollectionRepository(db.connection)
105
+ options.collection_ids = []
106
+ for name in collection:
107
+ coll = repo.get_by_name(name)
108
+ if coll:
109
+ options.collection_ids.append(coll.id)
110
+ elif not search_all:
111
+ with db.connection:
112
+ repo = CollectionRepository(db.connection)
113
+ enabled = repo.list_enabled()
114
+ options.collection_ids = [c.id for c in enabled]
115
+
116
+ with db.connection:
117
+ pipeline = SearchPipeline(
118
+ db.connection,
119
+ embedder=manager._model if manager else None, # noqa: SLF001
120
+ embedding_dim=embedding_dim,
121
+ )
122
+ return pipeline.search(query, options)
123
+
124
+ # Run evaluation
125
+ evaluator = SearchEvaluator(fixture_data)
126
+ try:
127
+ metrics = evaluator.evaluate(search_fn)
128
+ except Exception as e:
129
+ raise click.ClickException(f"Benchmark failed: {e}") from e
130
+
131
+ # Output results
132
+ if output_json:
133
+ console.print(json.dumps(metrics, indent=2))
134
+ else:
135
+ table = Table(title=f"Benchmark Results: {fixture.name}")
136
+ table.add_column("Metric", style="cyan")
137
+ table.add_column("Value", style="green", justify="right")
138
+
139
+ for key in sorted(metrics.keys()):
140
+ value = metrics[key]
141
+ if key == "num_queries":
142
+ table.add_row("Queries", f"{int(value)}")
143
+ elif key == "mrr":
144
+ table.add_row("MRR", f"{value:.4f}")
145
+ else:
146
+ table.add_row(key, f"{value:.4f}")
147
+
148
+ console.print(table)
@@ -0,0 +1,360 @@
1
+ """Collection management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.tree import Tree
11
+
12
+ from sif.core.models import Collection
13
+ from sif.database.database import Database
14
+ from sif.database.repositories import CollectionRepository
15
+
16
+
17
+ console = Console()
18
+
19
+
20
+ @click.group("collection")
21
+ def collection_group() -> None:
22
+ """Manage document collections."""
23
+ pass
24
+
25
+
26
+ @collection_group.command("add")
27
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True))
28
+ @click.option("--name", "-n", required=True, help="Collection name")
29
+ @click.option("--pattern", "-p", default="**/*.md", help="File pattern to match")
30
+ @click.option("--ignore", "-i", multiple=True, help="Patterns to ignore")
31
+ @click.option("--description", "-d", help="Collection description")
32
+ @click.option("--no-default", is_flag=True, help="Don't include in default searches")
33
+ @click.pass_context
34
+ def collection_add( # noqa: PLR0913
35
+ ctx: click.Context,
36
+ path: str,
37
+ name: str,
38
+ pattern: str,
39
+ ignore: tuple,
40
+ description: str | None,
41
+ no_default: bool,
42
+ ) -> None:
43
+ """Add a new collection."""
44
+ index_path = ctx.obj["index_path"]
45
+
46
+ db = Database(index_path)
47
+ db.init_schema()
48
+
49
+ with db.transaction() as conn:
50
+ repo = CollectionRepository(conn)
51
+
52
+ # Check if collection already exists
53
+ if repo.exists(name):
54
+ raise click.ClickException(f"Collection '{name}' already exists")
55
+
56
+ # Create collection
57
+ collection = Collection(
58
+ name=name,
59
+ path=str(Path(path).resolve()),
60
+ pattern=pattern,
61
+ ignore_patterns=list(ignore),
62
+ include_by_default=not no_default,
63
+ description=description,
64
+ )
65
+
66
+ repo.create(collection)
67
+
68
+ console.print(f"[green]Collection '{name}' added successfully[/green]")
69
+
70
+
71
+ @collection_group.command("remove")
72
+ @click.argument("name")
73
+ @click.confirmation_option(prompt="Are you sure you want to remove this collection?")
74
+ @click.pass_context
75
+ def collection_remove(ctx: click.Context, name: str) -> None:
76
+ """Remove a collection."""
77
+ index_path = ctx.obj["index_path"]
78
+
79
+ db = Database(index_path)
80
+ db.init_schema()
81
+
82
+ with db.transaction() as conn:
83
+ repo = CollectionRepository(conn)
84
+
85
+ collection = repo.get_by_name(name)
86
+ if not collection:
87
+ raise click.ClickException(f"Collection '{name}' not found")
88
+
89
+ # Delete documents first (cascade will handle chunks)
90
+ from sif.database.repositories import DocumentRepository # noqa: PLC0415
91
+
92
+ doc_repo = DocumentRepository(conn)
93
+ doc_repo.delete_by_collection(collection.id)
94
+
95
+ # Delete collection
96
+ repo.delete(collection.id)
97
+
98
+ console.print(f"[green]Collection '{name}' removed[/green]")
99
+
100
+
101
+ @collection_group.command("rename")
102
+ @click.argument("old_name")
103
+ @click.argument("new_name")
104
+ @click.pass_context
105
+ def collection_rename(ctx: click.Context, old_name: str, new_name: str) -> None:
106
+ """Rename a collection."""
107
+ index_path = ctx.obj["index_path"]
108
+
109
+ db = Database(index_path)
110
+ db.init_schema()
111
+
112
+ with db.transaction() as conn:
113
+ repo = CollectionRepository(conn)
114
+
115
+ collection = repo.get_by_name(old_name)
116
+ if not collection:
117
+ raise click.ClickException(f"Collection '{old_name}' not found")
118
+
119
+ # Check if new name already exists
120
+ if repo.exists(new_name):
121
+ raise click.ClickException(f"Collection '{new_name}' already exists")
122
+
123
+ collection.name = new_name
124
+ repo.update(collection)
125
+
126
+ console.print(f"[green]Collection renamed from '{old_name}' to '{new_name}'[/green]")
127
+
128
+
129
+ @collection_group.command("list")
130
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed information")
131
+ @click.pass_context
132
+ def collection_list(ctx: click.Context, verbose: bool) -> None:
133
+ """List all collections."""
134
+ index_path = ctx.obj["index_path"]
135
+
136
+ if not index_path.exists():
137
+ console.print("[yellow]No index found.[/yellow]")
138
+ return
139
+
140
+ db = Database(index_path)
141
+ db.init_schema()
142
+
143
+ with db.connection:
144
+ repo = CollectionRepository(db.connection)
145
+ collections = repo.list_all()
146
+
147
+ if not collections:
148
+ console.print("[yellow]No collections found.[/yellow]")
149
+ return
150
+
151
+ if verbose:
152
+ for collection in collections:
153
+ console.print(f"\n[bold cyan]{collection.name}[/bold cyan]")
154
+ console.print(f" Path: {collection.path}")
155
+ console.print(f" Pattern: {collection.pattern}")
156
+ console.print(f" Documents: {collection.document_count}")
157
+ console.print(f" Chunks: {collection.chunk_count}")
158
+ if collection.description:
159
+ console.print(f" Description: {collection.description}")
160
+ else:
161
+ table = Table(title="Collections")
162
+ table.add_column("Name", style="cyan")
163
+ table.add_column("Path", style="green")
164
+ table.add_column("Documents", style="yellow", justify="right")
165
+ table.add_column("Default", style="blue")
166
+
167
+ for collection in collections:
168
+ table.add_row(
169
+ collection.name,
170
+ collection.path,
171
+ str(collection.document_count),
172
+ "✓" if collection.include_by_default else "✗",
173
+ )
174
+
175
+ console.print(table)
176
+
177
+
178
+ @collection_group.command("show")
179
+ @click.argument("name")
180
+ @click.pass_context
181
+ def collection_show(ctx: click.Context, name: str) -> None:
182
+ """Show collection details."""
183
+ index_path = ctx.obj["index_path"]
184
+
185
+ db = Database(index_path)
186
+ db.init_schema()
187
+
188
+ with db.connection:
189
+ repo = CollectionRepository(db.connection)
190
+ collection = repo.get_by_name(name)
191
+
192
+ if not collection:
193
+ raise click.ClickException(f"Collection '{name}' not found")
194
+
195
+ console.print(f"[bold cyan]{collection.name}[/bold cyan]")
196
+ console.print(f" ID: {collection.id}")
197
+ console.print(f" Path: {collection.path}")
198
+ console.print(f" Pattern: {collection.pattern}")
199
+ console.print(f" Ignore Patterns: {', '.join(collection.ignore_patterns) or 'None'}")
200
+ console.print(f" Include by Default: {'Yes' if collection.include_by_default else 'No'}")
201
+ console.print(f" Documents: {collection.document_count}")
202
+ console.print(f" Chunks: {collection.chunk_count}")
203
+ console.print(f" Created: {collection.created_at}")
204
+ console.print(f" Updated: {collection.updated_at}")
205
+ if collection.last_indexed_at:
206
+ console.print(f" Last Indexed: {collection.last_indexed_at}")
207
+ if collection.description:
208
+ console.print(f" Description: {collection.description}")
209
+
210
+
211
+ @collection_group.command("enable")
212
+ @click.argument("name")
213
+ @click.pass_context
214
+ def collection_enable(ctx: click.Context, name: str) -> None:
215
+ """Enable a collection for default searches."""
216
+ index_path = ctx.obj["index_path"]
217
+
218
+ db = Database(index_path)
219
+ db.init_schema()
220
+
221
+ with db.transaction() as conn:
222
+ repo = CollectionRepository(conn)
223
+ collection = repo.get_by_name(name)
224
+
225
+ if not collection:
226
+ raise click.ClickException(f"Collection '{name}' not found")
227
+
228
+ collection.include_by_default = True
229
+ repo.update(collection)
230
+
231
+ console.print(f"[green]Collection '{name}' enabled[/green]")
232
+
233
+
234
+ @collection_group.command("disable")
235
+ @click.argument("name")
236
+ @click.pass_context
237
+ def collection_disable(ctx: click.Context, name: str) -> None:
238
+ """Disable a collection from default searches."""
239
+ index_path = ctx.obj["index_path"]
240
+
241
+ db = Database(index_path)
242
+ db.init_schema()
243
+
244
+ with db.transaction() as conn:
245
+ repo = CollectionRepository(conn)
246
+ collection = repo.get_by_name(name)
247
+
248
+ if not collection:
249
+ raise click.ClickException(f"Collection '{name}' not found")
250
+
251
+ collection.include_by_default = False
252
+ repo.update(collection)
253
+
254
+ console.print(f"[green]Collection '{name}' disabled[/green]")
255
+
256
+
257
+ @collection_group.command("update-cmd")
258
+ @click.argument("name")
259
+ @click.option("--cmd", "-c", help="Shell command to run before indexing")
260
+ @click.option("--clear", is_flag=True, help="Clear the pre-update command")
261
+ @click.pass_context
262
+ def collection_update_cmd(ctx: click.Context, name: str, cmd: str | None, clear: bool) -> None:
263
+ """Set or clear a pre-index shell command for a collection."""
264
+ index_path = ctx.obj["index_path"]
265
+ db = Database(index_path)
266
+ db.init_schema()
267
+ with db.transaction() as conn:
268
+ repo = CollectionRepository(conn)
269
+ collection = repo.get_by_name(name)
270
+ if not collection:
271
+ raise click.ClickException(f"Collection '{name}' not found")
272
+ if clear:
273
+ collection.pre_update_cmd = None
274
+ elif cmd is not None:
275
+ collection.pre_update_cmd = cmd
276
+ else:
277
+ raise click.ClickException("Provide --cmd or --clear")
278
+ repo.update(collection)
279
+ if clear:
280
+ console.print(f"[green]Cleared pre-update command for '{name}'[/green]")
281
+ else:
282
+ console.print(f"[green]Set pre-update command for '{name}': {cmd}[/green]")
283
+
284
+
285
+ @collection_group.command("include")
286
+ @click.argument("name")
287
+ @click.pass_context
288
+ def collection_include(ctx: click.Context, name: str) -> None:
289
+ """Include a collection in default searches."""
290
+ index_path = ctx.obj["index_path"]
291
+ db = Database(index_path)
292
+ db.init_schema()
293
+ with db.transaction() as conn:
294
+ repo = CollectionRepository(conn)
295
+ collection = repo.get_by_name(name)
296
+ if not collection:
297
+ raise click.ClickException(f"Collection '{name}' not found")
298
+ collection.include_by_default = True
299
+ repo.update(collection)
300
+ console.print(f"[green]Collection '{name}' included in default searches[/green]")
301
+
302
+
303
+ @collection_group.command("exclude")
304
+ @click.argument("name")
305
+ @click.pass_context
306
+ def collection_exclude(ctx: click.Context, name: str) -> None:
307
+ """Exclude a collection from default searches."""
308
+ index_path = ctx.obj["index_path"]
309
+ db = Database(index_path)
310
+ db.init_schema()
311
+ with db.transaction() as conn:
312
+ repo = CollectionRepository(conn)
313
+ collection = repo.get_by_name(name)
314
+ if not collection:
315
+ raise click.ClickException(f"Collection '{name}' not found")
316
+ collection.include_by_default = False
317
+ repo.update(collection)
318
+ console.print(f"[green]Collection '{name}' excluded from default searches[/green]")
319
+
320
+
321
+ @collection_group.command("ls")
322
+ @click.argument("name")
323
+ @click.argument("subpath", required=False)
324
+ @click.pass_context
325
+ def collection_ls(ctx: click.Context, name: str, subpath: str | None) -> None: # noqa: ARG001
326
+ """List files in a collection."""
327
+ index_path = ctx.obj["index_path"]
328
+
329
+ db = Database(index_path)
330
+ db.init_schema()
331
+
332
+ with db.connection:
333
+ repo = CollectionRepository(db.connection)
334
+ collection = repo.get_by_name(name)
335
+
336
+ if not collection:
337
+ raise click.ClickException(f"Collection '{name}' not found")
338
+
339
+ # Scan files
340
+ from sif.indexing.scanner import FileScanner # noqa: PLC0415
341
+
342
+ scanner = FileScanner()
343
+ scan_result = scanner.scan(
344
+ Path(collection.path),
345
+ pattern=collection.pattern,
346
+ ignore_patterns=collection.ignore_patterns,
347
+ )
348
+
349
+ if not scan_result.files:
350
+ console.print("[yellow]No files found in collection.[/yellow]")
351
+ return
352
+
353
+ # Build tree
354
+ tree = Tree(f"[bold cyan]{collection.name}[/bold cyan]")
355
+
356
+ for file_path in sorted(scan_result.files):
357
+ rel_path = file_path.relative_to(collection.path)
358
+ tree.add(str(rel_path))
359
+
360
+ console.print(tree)
@@ -0,0 +1,154 @@
1
+ """Context management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from sif.core.models import PathContext
10
+ from sif.database.database import Database
11
+ from sif.database.repositories import CollectionRepository, ContextRepository
12
+
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group("context")
18
+ def context_group() -> None:
19
+ """Manage contextual descriptions for paths, collections, and global scope."""
20
+ pass
21
+
22
+
23
+ @context_group.command("add")
24
+ @click.argument("type", type=click.Choice(["path", "collection", "global"]))
25
+ @click.argument("target")
26
+ @click.argument("content")
27
+ @click.pass_context
28
+ def context_add(
29
+ ctx: click.Context,
30
+ type: str, # noqa: A002
31
+ target: str,
32
+ content: str,
33
+ ) -> None:
34
+ """Add context for a path, collection, or global scope."""
35
+ index_path = ctx.obj["index_path"]
36
+ db = Database(index_path)
37
+ db.init_schema()
38
+
39
+ with db.transaction() as conn:
40
+ repo = ContextRepository(conn)
41
+
42
+ # Resolve target based on type
43
+ actual_target = target
44
+ if type == "collection":
45
+ coll_repo = CollectionRepository(conn)
46
+ coll = coll_repo.get_by_name(target)
47
+ if not coll:
48
+ # Fallback: treat target as collection ID
49
+ coll = coll_repo.get_by_id(target)
50
+ if not coll:
51
+ raise click.ClickException(f"Collection '{target}' not found")
52
+ actual_target = coll.id
53
+ elif type == "global":
54
+ actual_target = "global"
55
+ # For path type, actual_target = target (the path string)
56
+
57
+ # Upsert: update if exists for this target+type, else create
58
+ existing = repo.get_by_target(actual_target, type)
59
+ if existing:
60
+ existing.context = content
61
+ repo.update(existing)
62
+ console.print(f"[green]Context updated for {type} '{target}'[/green]")
63
+ else:
64
+ path_context = PathContext(
65
+ path=actual_target,
66
+ context=content,
67
+ context_type=type,
68
+ )
69
+ repo.create(path_context)
70
+ console.print(f"[green]Context added for {type} '{target}'[/green]")
71
+
72
+
73
+ @context_group.command("remove")
74
+ @click.argument("context_id")
75
+ @click.pass_context
76
+ def context_remove(ctx: click.Context, context_id: str) -> None:
77
+ """Remove a context by its ID."""
78
+ index_path = ctx.obj["index_path"]
79
+ db = Database(index_path)
80
+ db.init_schema()
81
+
82
+ with db.transaction() as conn:
83
+ repo = ContextRepository(conn)
84
+ deleted = repo.delete(context_id)
85
+ if not deleted:
86
+ raise click.ClickException(f"No context found with ID '{context_id}'")
87
+
88
+ console.print(f"[green]Context '{context_id}' removed[/green]")
89
+
90
+
91
+ @context_group.command("list")
92
+ @click.option(
93
+ "--type",
94
+ "context_type",
95
+ type=click.Choice(["path", "collection", "global"]),
96
+ help="Filter by context type",
97
+ )
98
+ @click.pass_context
99
+ def context_list(ctx: click.Context, context_type: str | None) -> None:
100
+ """List all contexts."""
101
+ index_path = ctx.obj["index_path"]
102
+
103
+ if not index_path.exists():
104
+ console.print("[yellow]No index found.[/yellow]")
105
+ return
106
+
107
+ db = Database(index_path)
108
+ db.init_schema()
109
+
110
+ with db.connection:
111
+ repo = ContextRepository(db.connection)
112
+ contexts = repo.list_by_type(context_type) if context_type else repo.list_all()
113
+
114
+ if not contexts:
115
+ console.print("[yellow]No contexts found.[/yellow]")
116
+ return
117
+
118
+ table = Table(title="Contexts")
119
+ table.add_column("Type", style="magenta")
120
+ table.add_column("Target", style="cyan")
121
+ table.add_column("Content", style="green")
122
+
123
+ for ctx_item in contexts:
124
+ # Truncate long contexts for display
125
+ content_text = ctx_item.context
126
+ if len(content_text) > 50:
127
+ content_text = content_text[:47] + "..."
128
+ table.add_row(ctx_item.context_type, ctx_item.path, content_text)
129
+
130
+ console.print(table)
131
+
132
+
133
+ @context_group.command("prune")
134
+ @click.pass_context
135
+ def context_prune(ctx: click.Context) -> None:
136
+ """Remove orphaned path contexts (whose target paths no longer exist in the index)."""
137
+ index_path = ctx.obj["index_path"]
138
+
139
+ if not index_path.exists():
140
+ console.print("[yellow]No index found.[/yellow]")
141
+ return
142
+
143
+ db = Database(index_path)
144
+ db.init_schema()
145
+
146
+ with db.transaction() as conn:
147
+ repo = ContextRepository(conn)
148
+ count = repo.delete_orphaned_paths()
149
+
150
+ console.print(f"[green]Pruned {count} orphaned path context(s).[/green]")
151
+
152
+
153
+ # Register 'rm' as an alias for 'remove'
154
+ context_group.add_command(context_remove, name="rm")