diff --git a/src/context8/cli/commands/lifecycle.py b/src/context8/cli/commands/lifecycle.py index cb1cbba..0c62cc9 100644 --- a/src/context8/cli/commands/lifecycle.py +++ b/src/context8/cli/commands/lifecycle.py @@ -31,7 +31,7 @@ def start(detach: bool): console.print(f" gRPC endpoint: [cyan]{DB_URL}[/]\n") else: console.print(f"\n[red]X Failed:[/] {msg}") - console.print(" Is Docker Desktop running?\n") + console.print(" Is your container runtime (Docker or Podman) running?\n") raise SystemExit(1) @@ -58,7 +58,7 @@ def init(seed: bool, github: bool, force: bool): """Initialize Context8 — start DB, create collection, download models, seed. This is the one-stop setup command. It: - 1. Starts Docker container if not running + 1. Starts the DB container if not running (Docker or Podman) 2. Creates the Actian VectorAI DB collection 3. Downloads the embedding model (~80MB, cached after first run) 4. Seeds with starter data (if --seed) @@ -66,8 +66,8 @@ def init(seed: bool, github: bool, force: bool): """ console.print("\n[bold blue]Context8[/] Initializing...\n") - # Step 1: Ensure Docker is running - console.print(" [dim]1/4[/] Docker container...", end="") + # Step 1: Ensure container runtime is running + console.print(" [dim]1/4[/] DB container...", end="") try: from ...docker import ensure_running @@ -76,7 +76,7 @@ def init(seed: bool, github: bool, force: bool): console.print(f" [green]OK[/] {msg}") else: console.print(f" [red]X[/] {msg}") - console.print(" Is Docker Desktop running?\n") + console.print(" Is your container runtime (Docker or Podman) running?\n") raise SystemExit(1) except ImportError: console.print(" [yellow]skipped[/] (check Docker manually)") diff --git a/src/context8/cli/commands/ops.py b/src/context8/cli/commands/ops.py index a548bba..76b103d 100644 --- a/src/context8/cli/commands/ops.py +++ b/src/context8/cli/commands/ops.py @@ -60,12 +60,30 @@ def doctor(): checks: list[tuple[str, bool, str]] = [] - try: - result = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=5) - docker_ok = result.returncode == 0 - checks.append(("Docker", docker_ok, "running" if docker_ok else "not running")) - except (FileNotFoundError, subprocess.TimeoutExpired): - checks.append(("Docker", False, "not found — install Docker Desktop")) + from ...docker import detect_runtime + + runtime = detect_runtime() + if runtime is None: + checks.append( + ( + "Container runtime", + False, + "not found — install Docker or Podman", + ) + ) + else: + try: + result = subprocess.run([runtime, "info"], capture_output=True, text=True, timeout=5) + running = result.returncode == 0 + checks.append( + ( + f"Container runtime ({runtime})", + running, + "running" if running else "installed but daemon not reachable", + ) + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + checks.append((f"Container runtime ({runtime})", False, "probe failed")) try: from ...docker import is_container_running diff --git a/src/context8/cli/commands/serve.py b/src/context8/cli/commands/serve.py index 3a53e19..aa7fa45 100644 --- a/src/context8/cli/commands/serve.py +++ b/src/context8/cli/commands/serve.py @@ -1,13 +1,63 @@ from __future__ import annotations import asyncio +import sys import click +def _log(msg: str) -> None: + """Bootstrap logging — stderr only, never stdout (stdio MCP uses stdout).""" + print(f"[context8] {msg}", file=sys.stderr, flush=True) + + +def _bootstrap() -> None: + """Idempotent bootstrap: container up, collection ready, models cached. + + Safe to run on every `serve` invocation — each step is a no-op when already + satisfied. All output goes to stderr so the MCP stdio protocol stays clean. + """ + from ...docker import ensure_running, is_container_running + + if not is_container_running(): + _log("starting DB container...") + ok, msg = ensure_running(timeout_secs=30) + if not ok: + _log(f"FATAL: container failed to start: {msg}") + raise SystemExit(1) + _log(f"container ready ({msg})") + + from ...storage import StorageService + + storage = StorageService() + created = storage.initialize() + if created: + _log("collection created") + storage.close() + + try: + from ...embeddings import EmbeddingService + + EmbeddingService.ensure_models_downloaded() + except Exception as e: + _log(f"warning: model pre-download failed ({e}) — will lazy-load") + + @click.command() -def serve(): - """Start the Context8 MCP server (stdio transport).""" +@click.option( + "--no-bootstrap", + is_flag=True, + help="Skip auto-start of container/collection (assume already initialized)", +) +def serve(no_bootstrap: bool): + """Start the Context8 MCP server (stdio transport). + + By default, ensures the DB container is up and the collection exists before + starting the server, so a single `context8 serve` works from a cold machine. + """ + if not no_bootstrap: + _bootstrap() + from ...mcp import run_server asyncio.run(run_server()) diff --git a/src/context8/config.py b/src/context8/config.py index d1ec7df..ec7d7d1 100644 --- a/src/context8/config.py +++ b/src/context8/config.py @@ -94,7 +94,20 @@ def continue_config_path() -> Path: def get_server_command() -> list[str]: - return ["python", "-m", "context8.mcp.server"] + """Absolute command Claude Code (or any agent) should launch for the MCP server. + + Routes through the `serve` CLI so the auto-bootstrap (container, collection, + models) runs before the stdio loop starts. Prefers the installed `context8` + script on PATH; falls back to the current interpreter so the plugin works + even when the entry-point script isn't globally available. + """ + import shutil + import sys + + script = shutil.which("context8") + if script: + return [script, "serve"] + return [sys.executable, "-m", "context8", "serve"] SUPPORTED_AGENTS = { diff --git a/src/context8/docker.py b/src/context8/docker.py index a9e5926..7dd8172 100644 --- a/src/context8/docker.py +++ b/src/context8/docker.py @@ -1,8 +1,9 @@ -"""Docker management for Context8 — start/stop/check the Actian VectorAI DB container. +"""Container runtime management for Context8 — start/stop/check the Actian +VectorAI DB container. -Generates docker-compose.yml on demand into ~/.context8/ so it works -whether installed via pip, uv, or from source. Never requires the user -to have a compose file in their project. +Generates a compose file on demand into ~/.context8/ so it works whether +installed via pip, uv, or from source. Supports both Docker and Podman — +the runtime is detected automatically. """ from __future__ import annotations @@ -19,7 +20,6 @@ CONTEXT8_DIR = _home() / ".context8" COMPOSE_TEMPLATE = """\ -version: "3.8" services: vectoraidb: image: docker.io/williamimoh/actian-vectorai-db:latest @@ -34,6 +34,9 @@ CONTAINER_NAME = "context8_db" +_runtime_cache: str | None = None +_compose_cache: list[str] | None = None + def _compose_dir() -> Path: """Return ~/.context8/ — where we keep the generated compose file.""" @@ -55,25 +58,92 @@ def _ensure_compose_file() -> Path: return compose_path -def _docker_compose_cmd() -> list[str]: - """Return the docker compose command (v2 first, v1 fallback).""" +def _probe(cmd: list[str]) -> bool: try: - subprocess.run( - ["docker", "compose", "version"], - capture_output=True, - check=True, - ) - return ["docker", "compose"] - except (subprocess.CalledProcessError, FileNotFoundError): - return ["docker-compose"] + subprocess.run(cmd, capture_output=True, check=True, timeout=5) + return True + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def detect_runtime() -> str | None: + """Return 'docker' or 'podman' — the first runtime whose daemon is reachable. + + Prefers a *working* runtime over a merely installed one: if docker is + installed but its daemon is down, falls through to podman. Returns None + if neither is usable. + """ + global _runtime_cache + if _runtime_cache is not None: + return _runtime_cache or None + + # First pass: pick a runtime whose daemon is actually reachable. + for candidate in ("docker", "podman"): + if _probe([candidate, "info"]): + _runtime_cache = candidate + return candidate + + # Second pass: any installed runtime, even if the daemon is down — so + # callers can still surface a useful error message. + for candidate in ("docker", "podman"): + if _probe([candidate, "--version"]): + _runtime_cache = candidate + return candidate + + _runtime_cache = "" + return None + + +def _compose_cmd() -> list[str] | None: + """Return the compose command for the detected runtime, or None. + + Tries (in order): `docker compose`, `docker-compose`, `podman compose`, + `podman-compose`. + """ + global _compose_cache + if _compose_cache is not None: + return _compose_cache or None + + candidates: list[list[str]] = [] + runtime = detect_runtime() + if runtime == "docker": + candidates = [["docker", "compose"], ["docker-compose"]] + elif runtime == "podman": + candidates = [["podman", "compose"], ["podman-compose"]] + else: + # Try everything anyway in case the runtime probe missed something + candidates = [ + ["docker", "compose"], + ["podman", "compose"], + ["podman-compose"], + ["docker-compose"], + ] + + for cmd in candidates: + if _probe(cmd + ["version"]): + _compose_cache = cmd + return cmd + + _compose_cache = [] + return None def run_compose(args: list[str]) -> subprocess.CompletedProcess: - """Run a docker compose command against the Context8 compose file.""" + """Run a compose command against the Context8 compose file.""" _ensure_compose_file() - cmd = _docker_compose_cmd() + args + cmd = _compose_cmd() + if cmd is None: + return subprocess.CompletedProcess( + args=args, + returncode=1, + stdout="", + stderr=( + "no compose tool found — install one of: " + "`docker compose`, `podman compose`, `podman-compose`, `docker-compose`" + ), + ) return subprocess.run( - cmd, + cmd + args, cwd=str(_compose_dir()), capture_output=True, text=True, @@ -81,10 +151,13 @@ def run_compose(args: list[str]) -> subprocess.CompletedProcess: def is_container_running() -> bool: - """Check if the context8_db container is running.""" + """Check if the context8_db container is running under docker or podman.""" + runtime = detect_runtime() + if runtime is None: + return False try: result = subprocess.run( - ["docker", "ps", "--filter", f"name={CONTAINER_NAME}", "--format", "{{{{.Status}}}}"], + [runtime, "ps", "--filter", f"name={CONTAINER_NAME}", "--format", "{{.Status}}"], capture_output=True, text=True, timeout=5, @@ -102,11 +175,12 @@ def ensure_running(timeout_secs: int = 30) -> tuple[bool, str]: if is_container_running(): return True, "already running" - logger.info("Starting Actian VectorAI DB container...") + runtime = detect_runtime() or "container runtime" + logger.info(f"Starting Actian VectorAI DB container via {runtime}...") result = run_compose(["up", "-d"]) if result.returncode != 0: - return False, f"docker compose up failed: {result.stderr.strip()}" + return False, f"compose up failed: {result.stderr.strip()}" # Wait for the DB to accept connections for _ in range(timeout_secs): diff --git a/src/context8/ingest/seed.py b/src/context8/ingest/seed.py index b8827a0..4575c29 100644 --- a/src/context8/ingest/seed.py +++ b/src/context8/ingest/seed.py @@ -25,7 +25,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ModuleNotFoundError", "language": "python", "tags": ["virtual-env", "opencv", "import"], - "confidence": 0.95, }, { "slug": "py-pep668", @@ -34,7 +33,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ExternallyManagedEnvironment", "language": "python", "tags": ["pip", "pep668", "ubuntu", "venv"], - "confidence": 0.98, }, { "slug": "py-annotated-38", @@ -43,7 +41,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ImportError", "language": "python", "tags": ["typing", "backport", "python-version"], - "confidence": 0.97, }, { "slug": "py-asyncio-jupyter", @@ -53,7 +50,6 @@ def slug_to_id(slug: str) -> str: "language": "python", "framework": "jupyter", "tags": ["asyncio", "jupyter", "event-loop"], - "confidence": 0.96, }, { "slug": "py-torch-cuda-oom", @@ -63,7 +59,6 @@ def slug_to_id(slug: str) -> str: "language": "python", "framework": "pytorch", "tags": ["cuda", "oom", "fine-tuning", "gpu"], - "confidence": 0.93, }, { "slug": "npm-eresolve-peer", @@ -72,7 +67,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ERESOLVE", "language": "javascript", "tags": ["npm", "peer-deps", "dependency-conflict"], - "confidence": 0.94, }, { "slug": "node-esm-cjs", @@ -81,7 +75,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ERR_REQUIRE_ESM", "language": "javascript", "tags": ["esm", "commonjs", "import", "require"], - "confidence": 0.93, }, { "slug": "node-heap-oom", @@ -90,7 +83,6 @@ def slug_to_id(slug: str) -> str: "error_type": "HeapOutOfMemory", "language": "javascript", "tags": ["heap", "memory", "build", "node-options"], - "confidence": 0.96, }, { "slug": "next-hydration", @@ -100,7 +92,6 @@ def slug_to_id(slug: str) -> str: "language": "typescript", "framework": "nextjs", "tags": ["hydration", "ssr", "nextjs"], - "confidence": 0.92, }, { "slug": "react-setstate-render", @@ -110,7 +101,6 @@ def slug_to_id(slug: str) -> str: "language": "typescript", "framework": "react", "tags": ["setState", "render", "useEffect"], - "confidence": 0.95, }, { "slug": "next-api-streaming", @@ -120,7 +110,6 @@ def slug_to_id(slug: str) -> str: "language": "typescript", "framework": "nextjs", "tags": ["api-route", "streaming", "app-router"], - "confidence": 0.91, }, { "slug": "docker-volume-empty", @@ -130,7 +119,6 @@ def slug_to_id(slug: str) -> str: "language": "", "framework": "docker", "tags": ["volume", "bind-mount", "wsl2", "file-sharing"], - "confidence": 0.91, }, { "slug": "docker-port-conflict", @@ -139,7 +127,6 @@ def slug_to_id(slug: str) -> str: "error_type": "PortConflict", "framework": "docker", "tags": ["port-conflict", "postgresql", "docker-compose"], - "confidence": 0.97, }, { "slug": "ts-never-array", @@ -148,7 +135,6 @@ def slug_to_id(slug: str) -> str: "error_type": "TS2322", "language": "typescript", "tags": ["type-narrowing", "never", "array-type"], - "confidence": 0.93, }, { "slug": "ts-path-aliases", @@ -157,7 +143,6 @@ def slug_to_id(slug: str) -> str: "error_type": "TS2307", "language": "typescript", "tags": ["path-aliases", "tsconfig", "module-resolution"], - "confidence": 0.96, }, { "slug": "db-pool-serverless", @@ -167,7 +152,6 @@ def slug_to_id(slug: str) -> str: "language": "typescript", "framework": "prisma", "tags": ["connection-pool", "serverless", "postgresql", "pgbouncer"], - "confidence": 0.94, }, { "slug": "git-lockfile-conflict", @@ -175,7 +159,6 @@ def slug_to_id(slug: str) -> str: "solution_text": "Never manually resolve lockfile conflicts. Fix: 1) Accept either version: git checkout --theirs package-lock.json, 2) Delete lockfile: rm package-lock.json, 3) Regenerate: npm install, 4) Commit the fresh lockfile.", "error_type": "MergeConflict", "tags": ["git", "merge-conflict", "lockfile", "npm"], - "confidence": 0.98, }, { "slug": "win-long-path", @@ -184,7 +167,6 @@ def slug_to_id(slug: str) -> str: "error_type": "ENOENT", "language": "javascript", "tags": ["windows", "long-path", "node_modules", "enoent"], - "confidence": 0.92, }, { "slug": "vite-prebundle", @@ -194,7 +176,6 @@ def slug_to_id(slug: str) -> str: "language": "typescript", "framework": "vite", "tags": ["vite", "prebundling", "cache", "dependency"], - "confidence": 0.94, }, { "slug": "rust-wasm-no-std", @@ -203,7 +184,6 @@ def slug_to_id(slug: str) -> str: "error_type": "E0463", "language": "rust", "tags": ["wasm", "no-std", "wasm-bindgen", "target"], - "confidence": 0.91, }, { "slug": "rust-borrow-loop", @@ -212,7 +192,6 @@ def slug_to_id(slug: str) -> str: "error_type": "E0502", "language": "rust", "tags": ["borrow-checker", "mutable", "lifetime"], - "confidence": 0.90, }, { "slug": "openai-rate-limit", @@ -221,7 +200,6 @@ def slug_to_id(slug: str) -> str: "error_type": "RateLimitError", "language": "python", "tags": ["openai", "rate-limit", "backoff", "async"], - "confidence": 0.95, }, { "slug": "hf-generate-gibberish", @@ -231,7 +209,6 @@ def slug_to_id(slug: str) -> str: "language": "python", "framework": "transformers", "tags": ["huggingface", "fine-tuning", "generation", "repetition"], - "confidence": 0.88, }, ] diff --git a/src/context8/search/engine.py b/src/context8/search/engine.py index f3c74ce..b6a8f1a 100644 --- a/src/context8/search/engine.py +++ b/src/context8/search/engine.py @@ -299,11 +299,11 @@ def _search_sparse( VectorAIError = av.exceptions.VectorAIError try: - sparse_vec = av.SparseVector(indices=indices, values=values) return self.storage.client.points.search( COLLECTION_NAME, - vector=sparse_vec, + vector=values, using="keywords", + sparse_indices=indices, filter=search_filter, limit=limit, with_payload=True, diff --git a/tests/test_agents.py b/tests/test_agents.py index 1238b03..481c1a8 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -58,8 +58,8 @@ def test_add_to_claude(self, tmp_path): data = json.loads(config_path.read_text()) assert "context8" in data["mcpServers"] entry = data["mcpServers"]["context8"] - assert entry["command"] == "python" - assert entry["args"] == ["-m", "context8.mcp.server"] + assert "command" in entry + assert isinstance(entry["args"], list) def test_idempotent(self, tmp_path): config_path = tmp_path / "settings.json"