diff --git a/README.md b/README.md index 73cec3e..cfaa8f2 100644 --- a/README.md +++ b/README.md @@ -77,17 +77,23 @@ and the honest caveats — is in [`docs/eval.md`](docs/eval.md). ### ⚡ One line: install + wire into your agent -Download, install, and register CodeRAG as your coding agent's search tool — in a single command: +Download, install, and register CodeRAG as your coding agent's search tool — in a single command. [pipx](https://pipx.pypa.io) drops it into an isolated environment and puts `coderag` on your `PATH` — exactly what your agent needs to launch the server: ```bash -pip install "coderag[mcp] @ git+https://github.com/Neverdecel/CodeRAG" && coderag install +pipx install "coderag[mcp] @ git+https://github.com/Neverdecel/CodeRAG" && coderag install ``` This installs the engine **and** MCP server, then `coderag install` auto-detects **Claude Code**, **Hermes**, and **Codex** (or launches an interactive wizard) and wires CodeRAG in. Restart your agent and it searches a warm, pre-indexed workspace instead of grepping. Preview without writing any config: `coderag install --print`. +> No pipx yet? `sudo apt install pipx` (Debian/Ubuntu) or `brew install pipx` (macOS), then `pipx ensurepath` and open a new shell. Prefer plain `pip`? Install into a virtual environment instead (see **Quick start** below) — a global `pip install` is blocked on Debian/Ubuntu and other [PEP 668](https://peps.python.org/pep-0668/) systems. + ## 🚀 Quick start +Work inside a virtual environment — a global `pip install` is blocked on Debian/Ubuntu and other [PEP 668](https://peps.python.org/pep-0668/) systems (`error: externally-managed-environment`): + ```bash +python3 -m venv .venv && source .venv/bin/activate + pip install -e . # core engine (local embeddings included) # optional extras: pip install -e ".[server]" # HTTP/REST API @@ -98,6 +104,11 @@ pip install -e ".[anthropic]" # Anthropic (Claude) LLM answers pip install -e ".[all]" # everything above ``` +> Wiring the MCP server into an agent from a venv? Run `coderag install` through the venv's +> binary (`.venv/bin/coderag install`) rather than after `activate`, so it records the venv's +> Python by absolute path — otherwise the bare `coderag` it writes won't resolve when your agent +> launches the server. Or use **pipx** (above), which keeps `coderag` on `PATH` for good. + Index a codebase and search it — no configuration, no API key: ```bash diff --git a/coderag/install.py b/coderag/install.py index 8ffeda6..a421a11 100644 --- a/coderag/install.py +++ b/coderag/install.py @@ -64,15 +64,68 @@ class Plan: # --- shared helpers ------------------------------------------------------------------- -def _server_invocation(watched_dir: Optional[Path]) -> Tuple[str, List[str]]: - """How an agent should launch the server: ``coderag mcp`` if on PATH, else ``python -m``. +def _within(path: str, prefix: str) -> bool: + """Whether ``path`` *lives* inside ``prefix`` — its own location, symlink not followed. + + The directory is resolved (to normalise ``..`` / symlinked tmp roots) but the final + entry is not, so a launcher symlink (e.g. a pipx shim) is judged by where it sits, not + by where it points. + """ + p = Path(path) + loc = p.parent.resolve() / p.name + try: + loc.relative_to(Path(prefix).resolve()) + return True + except ValueError: + return False + + +def _env_console_script() -> Optional[str]: + """Absolute path to the ``coderag`` console script in *this* interpreter's bin dir. + + ``None`` when there isn't one (e.g. running from a source checkout). Searching only + ``sys.executable``'s directory ties the result to the Python we're running under and + handles the platform's script extension (``coderag.exe`` on Windows). The directory is + taken lexically — *not* via ``resolve()``, which would follow a venv's ``python`` + symlink back to the base interpreter and miss the venv's own ``coderag`` script. + """ + bindir = Path(sys.executable).parent + return shutil.which("coderag", path=str(bindir)) + - Mirrors the README's launcher note (``README.md:210``). When ``watched_dir`` is given, - a ``--watched-dir`` arg is appended so a globally-configured agent indexes the right - tree regardless of where it was launched. +def _bare_coderag_is_durable() -> bool: + """Whether a bare ``coderag`` will resolve for the *agent*, not just for us right now. + + The agent launches the server from its own shell, so a bare command is only safe when + ``coderag`` is on PATH via a location that survives without venv activation: a non-venv + (system / user) install, or a launcher outside the current venv prefix (e.g. a pipx + shim in ``~/.local/bin``). It is *not* safe when the only ``coderag`` on PATH is the + active venv's own ``bin/coderag``, which vanishes the moment the venv is deactivated — + the activation footgun this guards against. + """ + on_path = shutil.which("coderag") + if not on_path: + return False + in_venv = sys.prefix != sys.base_prefix + if not in_venv: + return True + return not _within(on_path, sys.prefix) + + +def _server_invocation(watched_dir: Optional[Path]) -> Tuple[str, List[str]]: + """How an agent should launch the server. + + The agent runs this from *its* environment, not ours, so a bare ``coderag`` is written + only when it will resolve on a PATH the agent also has (:func:`_bare_coderag_is_durable`). + Otherwise we pin an absolute target — this environment's ``coderag`` console script, or + `` -m coderag.surfaces.cli`` — so the server still launches when the install-time + venv isn't active. When ``watched_dir`` is given, ``--watched-dir`` is appended so a + globally-configured agent indexes the right tree regardless of where it was launched. """ - if shutil.which("coderag"): + if _bare_coderag_is_durable(): command, args = "coderag", ["mcp"] + elif (script := _env_console_script()) is not None: + command, args = script, ["mcp"] else: command, args = sys.executable, ["-m", "coderag.surfaces.cli", "mcp"] if watched_dir is not None: diff --git a/tests/test_install.py b/tests/test_install.py index 5a9e67e..de75daf 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -121,3 +121,73 @@ def test_wizard_collects_choices(home, monkeypatch): assert len(plans) == 1 assert plans[0].target == "hermes" assert plans[0].tools == inst.DEFAULT_TOOLS + + +# --- launcher resolution (the venv-activation footgun) -------------------------------- +# +# An agent launches the server from its own shell, so the command written into its config +# must resolve there — not merely wherever `coderag install` happened to run. + + +def _patch_venv(monkeypatch, *, prefix: str, base_prefix: str) -> None: + monkeypatch.setattr(inst.sys, "prefix", prefix) + monkeypatch.setattr(inst.sys, "base_prefix", base_prefix) + + +def test_invocation_bare_for_system_install(monkeypatch): + # Not in a venv and `coderag` on PATH → a durable bare command. + _patch_venv(monkeypatch, prefix="/usr", base_prefix="/usr") + monkeypatch.setattr(inst.shutil, "which", lambda *_a, **_k: "/usr/bin/coderag") + assert inst._server_invocation(None) == ("coderag", ["mcp"]) + + +def test_invocation_absolute_inside_activated_venv(monkeypatch, tmp_path): + # The only `coderag` on PATH is the active venv's own bin/coderag, which disappears + # once deactivated → pin that script by absolute path instead of a bare command. + bindir = tmp_path / ".venv" / "bin" + bindir.mkdir(parents=True) + script = bindir / "coderag" + script.write_text("#!stub\n") + _patch_venv(monkeypatch, prefix=str(tmp_path / ".venv"), base_prefix="/usr") + monkeypatch.setattr(inst.sys, "executable", str(bindir / "python")) + monkeypatch.setattr(inst.shutil, "which", lambda *_a, **_k: str(script)) + assert inst._server_invocation(None) == (str(script), ["mcp"]) + + +def test_invocation_absolute_when_venv_not_activated(monkeypatch, tmp_path): + # Ran via `.venv/bin/coderag install` without activating: `coderag` is not on PATH, + # but the env's console script is found in sys.executable's bin dir. + bindir = tmp_path / ".venv" / "bin" + bindir.mkdir(parents=True) + script = bindir / "coderag" + script.write_text("#!stub\n") + _patch_venv(monkeypatch, prefix=str(tmp_path / ".venv"), base_prefix="/usr") + monkeypatch.setattr(inst.sys, "executable", str(bindir / "python")) + monkeypatch.setattr( + inst.shutil, "which", lambda _n, path=None: str(script) if path else None + ) + assert inst._server_invocation(None) == (str(script), ["mcp"]) + + +def test_invocation_bare_for_pipx_shim_outside_venv(monkeypatch, tmp_path): + # pipx: in a venv, but the PATH launcher lives outside the venv prefix (~/.local/bin), + # so a bare `coderag` resolves without activation. + (tmp_path / "venvs" / "coderag" / "bin").mkdir(parents=True) + shim = tmp_path / ".local" / "bin" / "coderag" + shim.parent.mkdir(parents=True) + shim.write_text("#!stub\n") + _patch_venv( + monkeypatch, prefix=str(tmp_path / "venvs" / "coderag"), base_prefix="/usr" + ) + monkeypatch.setattr(inst.shutil, "which", lambda *_a, **_k: str(shim)) + assert inst._server_invocation(None) == ("coderag", ["mcp"]) + + +def test_invocation_module_fallback_when_no_script(monkeypatch): + # Source checkout, no installed console script anywhere → run the module by interpreter. + _patch_venv(monkeypatch, prefix="/usr", base_prefix="/usr") + monkeypatch.setattr(inst.sys, "executable", "/usr/bin/python3") + monkeypatch.setattr(inst.shutil, "which", lambda *_a, **_k: None) + cmd, args = inst._server_invocation(None) + assert cmd == "/usr/bin/python3" + assert args == ["-m", "coderag.surfaces.cli", "mcp"]