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.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- 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")
|