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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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")
contextual/config.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+
6
+ def get_log_dir():
7
+ return pathlib.Path("~/.local/state/contextual/logs").expanduser()