contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
contextual/cli_docs.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""Phase 2 CLI extensions for Contextual.
|
|
2
|
+
|
|
3
|
+
Adds to the existing Typer app (``contextual/cli.py``):
|
|
4
|
+
|
|
5
|
+
Docs sub-commands:
|
|
6
|
+
contextual docs index — index all markdown/RST docs in a repo
|
|
7
|
+
contextual docs search — search indexed docs from the terminal
|
|
8
|
+
contextual docs files — list all indexed doc files
|
|
9
|
+
|
|
10
|
+
Setup sub-commands (one per MCP client):
|
|
11
|
+
contextual setup claude-desktop
|
|
12
|
+
contextual setup claude-code
|
|
13
|
+
contextual setup cursor
|
|
14
|
+
contextual setup vscode
|
|
15
|
+
contextual setup gemini-cli
|
|
16
|
+
contextual setup antigravity
|
|
17
|
+
|
|
18
|
+
HOW TO WIRE INTO cli.py
|
|
19
|
+
========================
|
|
20
|
+
Add at the bottom of ``contextual/cli.py``:
|
|
21
|
+
|
|
22
|
+
from contextual.cli_docs import docs_app, setup_app
|
|
23
|
+
app.add_typer(docs_app, name="docs", help="Index and search documentation.")
|
|
24
|
+
app.add_typer(setup_app, name="setup", help="Configure Contextual in your IDE/AI client.")
|
|
25
|
+
|
|
26
|
+
Nothing else needs to change. The existing ``app`` stays untouched.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import platform
|
|
33
|
+
import shlex
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import textwrap
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Annotated, Optional
|
|
41
|
+
|
|
42
|
+
import structlog
|
|
43
|
+
import typer
|
|
44
|
+
from rich.console import Console
|
|
45
|
+
from rich.panel import Panel
|
|
46
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
47
|
+
from rich.table import Table
|
|
48
|
+
|
|
49
|
+
logger = structlog.get_logger(__name__)
|
|
50
|
+
console = Console()
|
|
51
|
+
err_console = Console(stderr=True)
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Sub-app roots
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
docs_app = typer.Typer(no_args_is_help=True)
|
|
58
|
+
setup_app = typer.Typer(no_args_is_help=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Shared helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _resolve_repo(path: Optional[Path]) -> Path:
|
|
66
|
+
repo = (path or Path.cwd()).resolve()
|
|
67
|
+
contextual_dir = repo / ".contextual"
|
|
68
|
+
if not contextual_dir.exists():
|
|
69
|
+
console.print(
|
|
70
|
+
f"[red]✗[/red] Repository not initialised. Run [bold]contextual init[/bold] first."
|
|
71
|
+
)
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
return repo
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_pipeline(repo_path: Path) -> DocsPipeline:
|
|
77
|
+
"""Initialize DocsPipeline with its own embedder and DB pool."""
|
|
78
|
+
from contextual.docs.pipeline import DocsPipeline
|
|
79
|
+
from contextual.embedding import get_docs_embedder
|
|
80
|
+
from contextual.storage.sqlite_pool import SQLitePool
|
|
81
|
+
|
|
82
|
+
db_path = repo_path / ".contextual" / "contextual.db"
|
|
83
|
+
pool = SQLitePool(db_path)
|
|
84
|
+
embedder = get_docs_embedder()
|
|
85
|
+
return DocsPipeline(repo_root=repo_path, pool=pool, embedder=embedder)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# docs index
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@docs_app.command("index")
|
|
93
|
+
def docs_index(
|
|
94
|
+
path: Annotated[
|
|
95
|
+
Optional[Path],
|
|
96
|
+
typer.Argument(help="Repository path (default: current directory)"),
|
|
97
|
+
] = None,
|
|
98
|
+
force: Annotated[
|
|
99
|
+
bool,
|
|
100
|
+
typer.Option("--force", "-f", help="Re-index all files, ignore content hash cache."),
|
|
101
|
+
] = False,
|
|
102
|
+
watch: Annotated[
|
|
103
|
+
bool,
|
|
104
|
+
typer.Option("--watch", "-w", help="Keep running and watch for file changes."),
|
|
105
|
+
] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Index all documentation files (.md, .mdx, .rst, .txt) in a repository.
|
|
108
|
+
|
|
109
|
+
Runs heading-aware chunking, embeds chunks, and stores them alongside
|
|
110
|
+
Phase 1 code chunks in the same SQLite database.
|
|
111
|
+
|
|
112
|
+
Set CONTEXTUAL_DOCS_ENABLED=1 to activate docs tools in the MCP server.
|
|
113
|
+
"""
|
|
114
|
+
repo = _resolve_repo(path)
|
|
115
|
+
pipeline = _get_pipeline(repo)
|
|
116
|
+
|
|
117
|
+
console.print(f"\n[bold cyan]Contextual Docs Indexer[/bold cyan]")
|
|
118
|
+
console.print(f" Repository : [dim]{repo}[/dim]")
|
|
119
|
+
console.print(f" Force : [dim]{force}[/dim]\n")
|
|
120
|
+
|
|
121
|
+
discovered_count: list[int] = [0]
|
|
122
|
+
|
|
123
|
+
def _progress_cb(current: int, total: int, filename: str) -> None:
|
|
124
|
+
if total > 0 and discovered_count[0] != total:
|
|
125
|
+
discovered_count[0] = total
|
|
126
|
+
|
|
127
|
+
with Progress(
|
|
128
|
+
SpinnerColumn(),
|
|
129
|
+
TextColumn("[progress.description]{task.description}"),
|
|
130
|
+
BarColumn(),
|
|
131
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
132
|
+
console=console,
|
|
133
|
+
transient=True,
|
|
134
|
+
) as progress:
|
|
135
|
+
task = progress.add_task("Indexing docs…", total=None)
|
|
136
|
+
|
|
137
|
+
def _cb(current: int, total: int, filename: str) -> None:
|
|
138
|
+
progress.update(task, total=total, completed=current, description=f"[cyan]{filename}[/cyan]")
|
|
139
|
+
|
|
140
|
+
pipeline._progress = _cb # type: ignore[attr-defined]
|
|
141
|
+
stats = pipeline.index_all(force=force)
|
|
142
|
+
|
|
143
|
+
# Summary panel
|
|
144
|
+
summary = Table.grid(padding=(0, 2))
|
|
145
|
+
summary.add_row("[green]✓ Files indexed[/green]", str(stats.files_indexed))
|
|
146
|
+
summary.add_row("[dim] Unchanged (skipped)[/dim]", str(stats.files_skipped_unchanged))
|
|
147
|
+
summary.add_row("[red] Failed[/red]", str(stats.files_failed))
|
|
148
|
+
summary.add_row("[cyan] Chunks created[/cyan]", str(stats.chunks_created))
|
|
149
|
+
summary.add_row("[dim] Duration[/dim]", f"{stats.duration_seconds:.2f}s")
|
|
150
|
+
console.print(Panel(summary, title="Docs Index Complete", border_style="cyan"))
|
|
151
|
+
|
|
152
|
+
if stats.files_indexed > 0:
|
|
153
|
+
console.print(
|
|
154
|
+
"\n[dim]Tip:[/dim] Set [bold]CONTEXTUAL_DOCS_ENABLED=1[/bold] to activate "
|
|
155
|
+
"docs tools in your MCP server config.\n"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if watch:
|
|
159
|
+
_run_docs_watch(repo, pipeline)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_docs_watch(repo: Path, pipeline) -> None:
|
|
163
|
+
"""Start a blocking docs file watcher loop."""
|
|
164
|
+
from contextual.docs.watcher import DocsFileWatcher
|
|
165
|
+
|
|
166
|
+
console.print(f"\n[bold]Watching[/bold] [cyan]{repo}[/cyan] for doc changes…")
|
|
167
|
+
console.print("[dim]Press Ctrl+C to stop.[/dim]\n")
|
|
168
|
+
|
|
169
|
+
watcher = DocsFileWatcher(repo, pipeline)
|
|
170
|
+
watcher.start()
|
|
171
|
+
try:
|
|
172
|
+
import time
|
|
173
|
+
while True:
|
|
174
|
+
time.sleep(1)
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
pass
|
|
177
|
+
finally:
|
|
178
|
+
watcher.stop()
|
|
179
|
+
console.print("\n[dim]Watcher stopped.[/dim]")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# docs search
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@docs_app.command("search")
|
|
187
|
+
def docs_search_cmd(
|
|
188
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
189
|
+
path: Annotated[
|
|
190
|
+
Optional[Path],
|
|
191
|
+
typer.Option("--path", "-p", help="Repository path (default: cwd)"),
|
|
192
|
+
] = None,
|
|
193
|
+
k: Annotated[int, typer.Option("--top-k", "-k", help="Number of results")] = 5,
|
|
194
|
+
source_type: Annotated[
|
|
195
|
+
Optional[str],
|
|
196
|
+
typer.Option("--type", "-t", help="Filter: docs | docs_code"),
|
|
197
|
+
] = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Search indexed documentation semantically.
|
|
200
|
+
|
|
201
|
+
Uses hybrid BM25 + vector search with RRF fusion.
|
|
202
|
+
"""
|
|
203
|
+
repo = _resolve_repo(path)
|
|
204
|
+
pipeline = _get_pipeline(repo)
|
|
205
|
+
db_path = repo / ".contextual" / "contextual.db"
|
|
206
|
+
|
|
207
|
+
from contextual.docs.retrieval import docs_search
|
|
208
|
+
from contextual.embedding.embedder import get_docs_embedder
|
|
209
|
+
from contextual.storage import SQLitePool
|
|
210
|
+
|
|
211
|
+
pool = SQLitePool(db_path)
|
|
212
|
+
embedder = get_docs_embedder()
|
|
213
|
+
|
|
214
|
+
sf = {"source_type": [source_type]} if source_type else None
|
|
215
|
+
|
|
216
|
+
with pool.reader() as conn:
|
|
217
|
+
hits = docs_search(query=query, conn=conn, embedder=embedder, k=k, source_filter=sf)
|
|
218
|
+
|
|
219
|
+
if not hits:
|
|
220
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
221
|
+
raise typer.Exit(0)
|
|
222
|
+
|
|
223
|
+
console.print(f"\n[bold cyan]Docs Search[/bold cyan] — [dim]{query!r}[/dim]\n")
|
|
224
|
+
for i, hit in enumerate(hits, 1):
|
|
225
|
+
table = Table.grid(padding=(0, 1))
|
|
226
|
+
table.add_row(
|
|
227
|
+
f"[bold]{i}.[/bold]",
|
|
228
|
+
f"[cyan]{hit.file_path}[/cyan]",
|
|
229
|
+
f"[dim]{hit.heading_path}[/dim]" if hit.heading_path else "",
|
|
230
|
+
f"[green]score={hit.score:.4f}[/green]",
|
|
231
|
+
)
|
|
232
|
+
console.print(table)
|
|
233
|
+
snippet = hit.content[:200].replace("\n", " ").strip()
|
|
234
|
+
console.print(f" [dim]{snippet}…[/dim]\n")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# docs files
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
@docs_app.command("files")
|
|
242
|
+
def docs_files_cmd(
|
|
243
|
+
path: Annotated[
|
|
244
|
+
Optional[Path],
|
|
245
|
+
typer.Argument(help="Repository path (default: cwd)"),
|
|
246
|
+
] = None,
|
|
247
|
+
repo_filter: Annotated[
|
|
248
|
+
Optional[str],
|
|
249
|
+
typer.Option("--repo", help="Path prefix filter (e.g. 'docs')"),
|
|
250
|
+
] = None,
|
|
251
|
+
glob: Annotated[
|
|
252
|
+
Optional[str],
|
|
253
|
+
typer.Option("--glob", help="fnmatch glob (e.g. 'docs/adr/*.md')"),
|
|
254
|
+
] = None,
|
|
255
|
+
headings: Annotated[
|
|
256
|
+
bool,
|
|
257
|
+
typer.Option("--headings", help="Show heading structure per file"),
|
|
258
|
+
] = False,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""List all documentation files indexed in this repository."""
|
|
261
|
+
repo = _resolve_repo(path)
|
|
262
|
+
db_path = repo / ".contextual" / "contextual.db"
|
|
263
|
+
|
|
264
|
+
from contextual.docs.retrieval import docs_list_files
|
|
265
|
+
from contextual.storage import SQLitePool
|
|
266
|
+
|
|
267
|
+
pool = SQLitePool(db_path)
|
|
268
|
+
with pool.reader() as conn:
|
|
269
|
+
files = docs_list_files(conn=conn, repo=repo_filter, glob=glob, include_headings=headings)
|
|
270
|
+
|
|
271
|
+
if not files:
|
|
272
|
+
console.print("[yellow]No indexed doc files found.[/yellow]")
|
|
273
|
+
raise typer.Exit(0)
|
|
274
|
+
|
|
275
|
+
table = Table(title=f"Indexed Docs ({len(files)} files)", border_style="cyan")
|
|
276
|
+
table.add_column("File", style="cyan")
|
|
277
|
+
table.add_column("Chunks", justify="right")
|
|
278
|
+
if headings:
|
|
279
|
+
table.add_column("Headings")
|
|
280
|
+
|
|
281
|
+
for fi in files:
|
|
282
|
+
row = [fi.file_path, str(fi.chunk_count)]
|
|
283
|
+
if headings:
|
|
284
|
+
row.append(", ".join(fi.headings[:5]) + ("…" if len(fi.headings) > 5 else ""))
|
|
285
|
+
table.add_row(*row)
|
|
286
|
+
|
|
287
|
+
console.print(table)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# setup helpers
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
_UVX_PATH_HINT = (
|
|
295
|
+
"The MCP server runs via uvx. On macOS the GUI apps (Claude Desktop, Cursor) "
|
|
296
|
+
"inherit a sparse PATH that excludes ~/.local/bin. We write the absolute path."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def _find_uvx() -> str:
|
|
300
|
+
"""Find absolute path to uvx binary."""
|
|
301
|
+
# uv's default install location
|
|
302
|
+
candidates = [
|
|
303
|
+
Path.home() / ".local" / "bin" / "uvx",
|
|
304
|
+
Path.home() / ".cargo" / "bin" / "uvx",
|
|
305
|
+
Path("/opt/homebrew/bin/uvx"),
|
|
306
|
+
Path("/usr/local/bin/uvx"),
|
|
307
|
+
]
|
|
308
|
+
for c in candidates:
|
|
309
|
+
if c.exists():
|
|
310
|
+
return str(c)
|
|
311
|
+
# Fall back to which
|
|
312
|
+
found = shutil.which("uvx")
|
|
313
|
+
if found:
|
|
314
|
+
return found
|
|
315
|
+
return "uvx" # best-effort; user must fix PATH themselves
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _backup_and_write(config_path: Path, new_content: str, label: str) -> None:
|
|
319
|
+
"""Backup existing config then write new content."""
|
|
320
|
+
if config_path.exists():
|
|
321
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
322
|
+
backup = config_path.with_suffix(f".bak.{ts}")
|
|
323
|
+
shutil.copy2(config_path, backup)
|
|
324
|
+
console.print(f" [dim]Backed up existing config → {backup.name}[/dim]")
|
|
325
|
+
|
|
326
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
config_path.write_text(new_content, encoding="utf-8")
|
|
328
|
+
console.print(f" [green]✓[/green] Written: [cyan]{config_path}[/cyan]")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _merge_mcp_servers(config_path: Path, server_key: str, server_entry: dict, root_key: str = "mcpServers") -> None:
|
|
332
|
+
"""Merge a single MCP server entry into an existing JSON config file."""
|
|
333
|
+
existing: dict = {}
|
|
334
|
+
if config_path.exists():
|
|
335
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
336
|
+
backup = config_path.with_suffix(f".bak.{ts}")
|
|
337
|
+
shutil.copy2(config_path, backup)
|
|
338
|
+
console.print(f" [dim]Backed up → {backup.name}[/dim]")
|
|
339
|
+
try:
|
|
340
|
+
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
341
|
+
except json.JSONDecodeError:
|
|
342
|
+
console.print(f" [yellow]⚠[/yellow] Existing config has JSON errors — overwriting.")
|
|
343
|
+
|
|
344
|
+
if root_key not in existing:
|
|
345
|
+
existing[root_key] = {}
|
|
346
|
+
existing[root_key][server_key] = server_entry
|
|
347
|
+
|
|
348
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
|
350
|
+
console.print(f" [green]✓[/green] Merged into: [cyan]{config_path}[/cyan]")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _print_restart_note(client: str) -> None:
|
|
354
|
+
console.print(f"\n [bold yellow]↻[/bold yellow] Restart [bold]{client}[/bold] to load the new MCP server.\n")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# setup claude-desktop
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
@setup_app.command("claude-desktop")
|
|
362
|
+
def setup_claude_desktop(
|
|
363
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Configure Contextual in Claude Desktop (macOS).
|
|
366
|
+
|
|
367
|
+
Writes to ~/Library/Application Support/Claude/claude_desktop_config.json.
|
|
368
|
+
Backs up any existing config before modifying.
|
|
369
|
+
"""
|
|
370
|
+
if platform.system() != "Darwin":
|
|
371
|
+
console.print("[red]✗[/red] Claude Desktop setup is macOS only.")
|
|
372
|
+
raise typer.Exit(1)
|
|
373
|
+
|
|
374
|
+
uvx = _find_uvx()
|
|
375
|
+
env: dict = {}
|
|
376
|
+
if docs:
|
|
377
|
+
env["CONTEXTUAL_DOCS_ENABLED"] = "1"
|
|
378
|
+
|
|
379
|
+
entry = {
|
|
380
|
+
"command": uvx,
|
|
381
|
+
"args": ["contextual"],
|
|
382
|
+
"env": env,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
config_path = (
|
|
386
|
+
Path.home()
|
|
387
|
+
/ "Library"
|
|
388
|
+
/ "Application Support"
|
|
389
|
+
/ "Claude"
|
|
390
|
+
/ "claude_desktop_config.json"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
console.print("\n[bold cyan]Setup: Claude Desktop[/bold cyan]")
|
|
394
|
+
console.print(f" [dim]{_UVX_PATH_HINT}[/dim]\n")
|
|
395
|
+
_merge_mcp_servers(config_path, "contextual", entry, root_key="mcpServers")
|
|
396
|
+
_print_restart_note("Claude Desktop")
|
|
397
|
+
|
|
398
|
+
console.print(" [dim]Logs will appear at:[/dim]")
|
|
399
|
+
console.print(f" [dim]~/Library/Logs/Claude/mcp-server-contextual.log[/dim]\n")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# setup claude-code
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
@setup_app.command("claude-code")
|
|
407
|
+
def setup_claude_code(
|
|
408
|
+
scope: Annotated[
|
|
409
|
+
str,
|
|
410
|
+
typer.Option("--scope", help="Scope: user | project | local"),
|
|
411
|
+
] = "user",
|
|
412
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Configure Contextual in Claude Code via the claude CLI.
|
|
415
|
+
|
|
416
|
+
Runs: claude mcp add contextual --scope <scope> --transport stdio -- uvx contextual
|
|
417
|
+
"""
|
|
418
|
+
uvx = _find_uvx()
|
|
419
|
+
claude_bin = shutil.which("claude")
|
|
420
|
+
if not claude_bin:
|
|
421
|
+
console.print("[red]✗[/red] 'claude' CLI not found. Install Claude Code first.")
|
|
422
|
+
raise typer.Exit(1)
|
|
423
|
+
|
|
424
|
+
console.print("\n[bold cyan]Setup: Claude Code[/bold cyan]\n")
|
|
425
|
+
|
|
426
|
+
cmd = [
|
|
427
|
+
claude_bin, "mcp", "add",
|
|
428
|
+
"contextual",
|
|
429
|
+
"--scope", scope,
|
|
430
|
+
"--transport", "stdio",
|
|
431
|
+
"--",
|
|
432
|
+
uvx, "contextual",
|
|
433
|
+
]
|
|
434
|
+
if docs:
|
|
435
|
+
cmd = [claude_bin, "mcp", "add", "contextual",
|
|
436
|
+
"--scope", scope, "--transport", "stdio",
|
|
437
|
+
"--env", "CONTEXTUAL_DOCS_ENABLED=1",
|
|
438
|
+
"--", uvx, "contextual"]
|
|
439
|
+
|
|
440
|
+
console.print(f" Running: [dim]{shlex.join(cmd)}[/dim]\n")
|
|
441
|
+
try:
|
|
442
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
443
|
+
if result.returncode == 0:
|
|
444
|
+
console.print(f" [green]✓[/green] Contextual registered (scope={scope})")
|
|
445
|
+
else:
|
|
446
|
+
console.print(f" [red]✗[/red] claude CLI error:\n{result.stderr}")
|
|
447
|
+
raise typer.Exit(1)
|
|
448
|
+
except FileNotFoundError:
|
|
449
|
+
console.print("[red]✗[/red] 'claude' binary not executable.")
|
|
450
|
+
raise typer.Exit(1)
|
|
451
|
+
|
|
452
|
+
_print_restart_note("Claude Code")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# setup cursor
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
@setup_app.command("cursor")
|
|
460
|
+
def setup_cursor(
|
|
461
|
+
project: Annotated[
|
|
462
|
+
bool,
|
|
463
|
+
typer.Option("--project/--global", help="Write to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global)"),
|
|
464
|
+
] = True,
|
|
465
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Configure Contextual in Cursor IDE.
|
|
468
|
+
|
|
469
|
+
Writes .cursor/mcp.json in the current directory (project scope, default)
|
|
470
|
+
or ~/.cursor/mcp.json (global scope).
|
|
471
|
+
|
|
472
|
+
Also prints the one-click deeplink for your README badge.
|
|
473
|
+
"""
|
|
474
|
+
uvx = _find_uvx()
|
|
475
|
+
env: dict = {}
|
|
476
|
+
if docs:
|
|
477
|
+
env["CONTEXTUAL_DOCS_ENABLED"] = "1"
|
|
478
|
+
|
|
479
|
+
entry = {"command": uvx, "args": ["contextual"], "env": env}
|
|
480
|
+
|
|
481
|
+
if project:
|
|
482
|
+
config_path = Path.cwd() / ".cursor" / "mcp.json"
|
|
483
|
+
else:
|
|
484
|
+
config_path = Path.home() / ".cursor" / "mcp.json"
|
|
485
|
+
|
|
486
|
+
console.print("\n[bold cyan]Setup: Cursor[/bold cyan]")
|
|
487
|
+
console.print(f" Scope: [dim]{'project' if project else 'global'}[/dim]\n")
|
|
488
|
+
|
|
489
|
+
_merge_mcp_servers(config_path, "contextual", entry, root_key="mcpServers")
|
|
490
|
+
|
|
491
|
+
# Deeplink badge
|
|
492
|
+
import base64
|
|
493
|
+
badge_payload = base64.b64encode(
|
|
494
|
+
json.dumps({"mcpServers": {"contextual": entry}}).encode()
|
|
495
|
+
).decode()
|
|
496
|
+
deeplink = f"cursor://anysphere.cursor-deeplink/mcp/install?name=Contextual&config={badge_payload}"
|
|
497
|
+
console.print(f"\n [bold]One-click deeplink (add to README):[/bold]")
|
|
498
|
+
console.print(f" [dim]{deeplink[:120]}…[/dim]")
|
|
499
|
+
|
|
500
|
+
_print_restart_note("Cursor")
|
|
501
|
+
console.print(
|
|
502
|
+
" [yellow]⚠[/yellow] Cursor has a 40-tool ceiling. "
|
|
503
|
+
"If docs + code tools exceed it, some will be silently truncated.\n"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# setup vscode
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
@setup_app.command("vscode")
|
|
512
|
+
def setup_vscode(
|
|
513
|
+
workspace: Annotated[
|
|
514
|
+
bool,
|
|
515
|
+
typer.Option("--workspace/--user", help="Write to .vscode/mcp.json (workspace) or user settings"),
|
|
516
|
+
] = True,
|
|
517
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Configure Contextual in VS Code Copilot (Agent Mode).
|
|
520
|
+
|
|
521
|
+
VS Code uses 'servers' (NOT 'mcpServers') as the root key.
|
|
522
|
+
Writes .vscode/mcp.json in the current directory (workspace scope).
|
|
523
|
+
|
|
524
|
+
Requires: VS Code ≥ 1.102 with GitHub Copilot Chat extension.
|
|
525
|
+
Agent Mode must be active (tools are invisible in Ask/Edit mode).
|
|
526
|
+
"""
|
|
527
|
+
uvx = _find_uvx()
|
|
528
|
+
env: dict = {}
|
|
529
|
+
if docs:
|
|
530
|
+
env["CONTEXTUAL_DOCS_ENABLED"] = "1"
|
|
531
|
+
|
|
532
|
+
# VS Code requires "type": "stdio" explicitly — without it, it assumes HTTP
|
|
533
|
+
entry = {"type": "stdio", "command": uvx, "args": ["contextual"], "env": env}
|
|
534
|
+
|
|
535
|
+
if workspace:
|
|
536
|
+
config_path = Path.cwd() / ".vscode" / "mcp.json"
|
|
537
|
+
else:
|
|
538
|
+
# User settings — complex path, guide user instead
|
|
539
|
+
console.print(
|
|
540
|
+
"\n[yellow]User-scope VS Code MCP config requires manual edit.[/yellow]\n"
|
|
541
|
+
"Add to your User settings.json:\n"
|
|
542
|
+
)
|
|
543
|
+
console.print(json.dumps({"mcp": {"servers": {"contextual": entry}}}, indent=2))
|
|
544
|
+
raise typer.Exit(0)
|
|
545
|
+
|
|
546
|
+
console.print("\n[bold cyan]Setup: VS Code Copilot[/bold cyan]")
|
|
547
|
+
console.print(" [dim]Root key is 'servers' (not 'mcpServers') — VS Code specific![/dim]\n")
|
|
548
|
+
|
|
549
|
+
# VS Code mcp.json uses "servers" at root
|
|
550
|
+
_merge_mcp_servers(config_path, "contextual", entry, root_key="servers")
|
|
551
|
+
|
|
552
|
+
_print_restart_note("VS Code")
|
|
553
|
+
console.print(
|
|
554
|
+
" [dim]Ensure Agent Mode is active — MCP tools are invisible in Ask/Edit mode.[/dim]\n"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ---------------------------------------------------------------------------
|
|
559
|
+
# setup gemini-cli
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
@setup_app.command("gemini-cli")
|
|
563
|
+
def setup_gemini_cli(
|
|
564
|
+
project: Annotated[
|
|
565
|
+
bool,
|
|
566
|
+
typer.Option("--project/--global", help="Write to .gemini/settings.json (project) or ~/.gemini/settings.json (global)"),
|
|
567
|
+
] = False,
|
|
568
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Configure Contextual in Google Gemini CLI.
|
|
571
|
+
|
|
572
|
+
Writes to ~/.gemini/settings.json (global, default) or
|
|
573
|
+
.gemini/settings.json (project scope).
|
|
574
|
+
|
|
575
|
+
Note: Gemini CLI does NOT auto-load .env files for mcpServer env vars.
|
|
576
|
+
Env vars must be set in the shell or passed explicitly.
|
|
577
|
+
"""
|
|
578
|
+
uvx = _find_uvx()
|
|
579
|
+
env: dict = {}
|
|
580
|
+
if docs:
|
|
581
|
+
env["CONTEXTUAL_DOCS_ENABLED"] = "1"
|
|
582
|
+
|
|
583
|
+
entry: dict = {"command": uvx, "args": ["contextual"]}
|
|
584
|
+
if env:
|
|
585
|
+
entry["env"] = env
|
|
586
|
+
|
|
587
|
+
if project:
|
|
588
|
+
config_path = Path.cwd() / ".gemini" / "settings.json"
|
|
589
|
+
else:
|
|
590
|
+
config_path = Path.home() / ".gemini" / "settings.json"
|
|
591
|
+
|
|
592
|
+
console.print("\n[bold cyan]Setup: Gemini CLI[/bold cyan]")
|
|
593
|
+
console.print(f" Scope: [dim]{'project' if project else 'global'}[/dim]")
|
|
594
|
+
console.print(" [dim]⚠ Gemini CLI does not load .env — set CONTEXTUAL_DOCS_ENABLED in shell.[/dim]\n")
|
|
595
|
+
|
|
596
|
+
_merge_mcp_servers(config_path, "contextual", entry, root_key="mcpServers")
|
|
597
|
+
_print_restart_note("Gemini CLI (gemini)")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# setup antigravity
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
@setup_app.command("antigravity")
|
|
605
|
+
def setup_antigravity(
|
|
606
|
+
docs: Annotated[bool, typer.Option("--docs/--no-docs", help="Enable docs tools")] = False,
|
|
607
|
+
) -> None:
|
|
608
|
+
"""Configure Contextual in Google Antigravity IDE.
|
|
609
|
+
|
|
610
|
+
Writes to ~/.gemini/antigravity/mcp_config.json.
|
|
611
|
+
Antigravity uses 'serverUrl' (HTTP) or 'command' (stdio) — NOT 'url' or 'httpUrl'.
|
|
612
|
+
|
|
613
|
+
Note: Antigravity only supports global config (no per-workspace MCP config yet).
|
|
614
|
+
Your code is processed on Google's cloud — review data-residency implications.
|
|
615
|
+
"""
|
|
616
|
+
uvx = _find_uvx()
|
|
617
|
+
env: dict = {}
|
|
618
|
+
if docs:
|
|
619
|
+
env["CONTEXTUAL_DOCS_ENABLED"] = "1"
|
|
620
|
+
|
|
621
|
+
# Antigravity stdio entry — uses 'command' key (same as Gemini CLI)
|
|
622
|
+
entry: dict = {"command": uvx, "args": ["contextual"]}
|
|
623
|
+
if env:
|
|
624
|
+
entry["env"] = env
|
|
625
|
+
|
|
626
|
+
config_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
|
627
|
+
|
|
628
|
+
console.print("\n[bold cyan]Setup: Google Antigravity IDE[/bold cyan]")
|
|
629
|
+
console.print(" [dim]Global config only — no per-workspace support yet.[/dim]")
|
|
630
|
+
console.print(" [yellow]⚠[/yellow] [dim]Code is processed on Google cloud. Review data-residency.[/dim]\n")
|
|
631
|
+
|
|
632
|
+
_merge_mcp_servers(config_path, "contextual", entry, root_key="mcpServers")
|
|
633
|
+
_print_restart_note("Antigravity IDE")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
# setup (show all options)
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
@setup_app.callback(invoke_without_command=True)
|
|
641
|
+
def setup_callback(ctx: typer.Context) -> None:
|
|
642
|
+
"""Configure Contextual in your AI IDE or MCP client.
|
|
643
|
+
|
|
644
|
+
Available clients:
|
|
645
|
+
claude-desktop Claude Desktop (macOS)
|
|
646
|
+
claude-code Claude Code CLI
|
|
647
|
+
cursor Cursor IDE
|
|
648
|
+
vscode VS Code Copilot (Agent Mode)
|
|
649
|
+
gemini-cli Google Gemini CLI
|
|
650
|
+
antigravity Google Antigravity IDE
|
|
651
|
+
|
|
652
|
+
For Perplexity and ChatGPT Desktop, use UI-based configuration.
|
|
653
|
+
Run 'contextual setup --help' for copy-paste config snippets.
|
|
654
|
+
"""
|
|
655
|
+
if ctx.invoked_subcommand is None:
|
|
656
|
+
uvx = _find_uvx()
|
|
657
|
+
console.print("\n[bold cyan]Contextual MCP — Client Setup Guide[/bold cyan]\n")
|
|
658
|
+
|
|
659
|
+
table = Table(border_style="dim", show_header=True)
|
|
660
|
+
table.add_column("Client", style="cyan")
|
|
661
|
+
table.add_column("Command")
|
|
662
|
+
table.add_column("Status")
|
|
663
|
+
|
|
664
|
+
table.add_row("Claude Desktop", "contextual setup claude-desktop", "[green]✓ Supported[/green]")
|
|
665
|
+
table.add_row("Claude Code", "contextual setup claude-code", "[green]✓ Supported[/green]")
|
|
666
|
+
table.add_row("Cursor", "contextual setup cursor", "[green]✓ Supported[/green]")
|
|
667
|
+
table.add_row("VS Code Copilot", "contextual setup vscode", "[green]✓ Supported[/green]")
|
|
668
|
+
table.add_row("Gemini CLI", "contextual setup gemini-cli", "[green]✓ Supported[/green]")
|
|
669
|
+
table.add_row("Antigravity", "contextual setup antigravity", "[green]✓ Supported[/green]")
|
|
670
|
+
table.add_row("Perplexity", "UI only — no file config", "[yellow]Copy-paste[/yellow]")
|
|
671
|
+
table.add_row("ChatGPT Desktop", "UI only — needs mcp-proxy", "[yellow]Copy-paste[/yellow]")
|
|
672
|
+
|
|
673
|
+
console.print(table)
|
|
674
|
+
|
|
675
|
+
# Universal copy-paste snippet
|
|
676
|
+
snippet = {
|
|
677
|
+
"mcpServers": {
|
|
678
|
+
"contextual": {
|
|
679
|
+
"command": uvx,
|
|
680
|
+
"args": ["contextual"],
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
console.print(f"\n[bold]Universal config snippet:[/bold]")
|
|
685
|
+
console.print(f"[dim]{json.dumps(snippet, indent=2)}[/dim]\n")
|