diff --git a/agent/lsp/cli.py b/agent/lsp/cli.py index 121cfa5f9..139baa213 100644 --- a/agent/lsp/cli.py +++ b/agent/lsp/cli.py @@ -247,18 +247,13 @@ def _cmd_restart() -> int: def _cmd_which(server_id: str) -> int: - from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir - import shutil as _shutil + from agent.lsp.install import INSTALL_RECIPES, _existing_binary recipe = INSTALL_RECIPES.get(server_id) bin_name = (recipe or {}).get("bin", server_id) - staged = hermes_lsp_bin_dir() / bin_name - if staged.exists(): - sys.stdout.write(str(staged) + "\n") - return 0 - on_path = _shutil.which(bin_name) - if on_path: - sys.stdout.write(on_path + "\n") + resolved = _existing_binary(bin_name) + if resolved: + sys.stdout.write(resolved + "\n") return 0 sys.stderr.write(f"{server_id}: not installed\n") return 1 @@ -292,11 +287,9 @@ def _backend_warnings() -> list: suggestion across common platforms. """ import shutil as _shutil - from agent.lsp.install import hermes_lsp_bin_dir + from agent.lsp.install import _existing_binary notes: list = [] - bash_installed = _shutil.which("bash-language-server") is not None or ( - (hermes_lsp_bin_dir() / "bash-language-server").exists() - ) + bash_installed = _existing_binary("bash-language-server") is not None if bash_installed and _shutil.which("shellcheck") is None: notes.append( "bash-language-server is installed but shellcheck is missing — " diff --git a/agent/lsp/install.py b/agent/lsp/install.py index d4a80ec19..9193b0375 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -108,6 +108,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = { _install_locks: Dict[str, threading.Lock] = {} _install_results: Dict[str, Optional[str]] = {} _install_lock_meta = threading.Lock() +_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat") + + +def _is_windows() -> bool: + return os.name == "nt" def hermes_lsp_bin_dir() -> Path: @@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path: return p +def _native_binary_candidates(base: Path) -> list[Path]: + """Return platform-native executable candidates for a staged binary.""" + candidates = [base] + if _is_windows(): + existing = {str(base).lower()} + for suffix in _WINDOWS_WRAPPER_SUFFIXES: + candidate = Path(str(base) + suffix) + key = str(candidate).lower() + if key not in existing: + candidates.append(candidate) + existing.add(key) + return candidates + + def _existing_binary(name: str) -> Optional[str]: """Probe the staging dir + PATH for a binary named ``name``.""" - staged = hermes_lsp_bin_dir() / name - if staged.exists() and os.access(staged, os.X_OK): - return str(staged) + for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name): + if staged.exists() and os.access(staged, os.X_OK): + return str(staged) on_path = shutil.which(name) if on_path: return on_path + if _is_windows(): + for suffix in _WINDOWS_WRAPPER_SUFFIXES: + on_path = shutil.which(f"{name}{suffix}") + if on_path: + return on_path return None @@ -250,12 +274,7 @@ def _install_npm( # Find the bin nm_bin = staging / "node_modules" / ".bin" / bin_name - if os.name == "nt": - # On Windows npm sometimes drops `.cmd` shims - candidates = [nm_bin, nm_bin.with_suffix(".cmd")] - else: - candidates = [nm_bin] - for c in candidates: + for c in _native_binary_candidates(nm_bin): if c.exists(): # Symlink into our `lsp/bin/` for stable PATH access. link = hermes_lsp_bin_dir() / c.name @@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]: logger.warning("[install] go install errored for %s: %s", pkg, e) return None bin_path = staging / bin_name - if os.name == "nt": + if _is_windows(): bin_path = bin_path.with_suffix(".exe") if bin_path.exists(): return str(bin_path) @@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]: except (subprocess.TimeoutExpired, OSError) as e: logger.warning("[install] pip install errored for %s: %s", pkg, e) return None - # Look for the script - bin_path = pip_target / "bin" / bin_name - if bin_path.exists(): - link = hermes_lsp_bin_dir() / bin_name - if not link.exists(): - try: - link.symlink_to(bin_path) - except (OSError, NotImplementedError): - try: - shutil.copy2(bin_path, link) - except OSError: - return str(bin_path) - return str(link if link.exists() else bin_path) + # Look for the console script. POSIX wheels generally write to bin/, + # while native Windows installs use Scripts/. + script_dirs = [pip_target / "bin"] + if _is_windows(): + script_dirs.append(pip_target / "Scripts") + for script_dir in script_dirs: + for bin_path in _native_binary_candidates(script_dir / bin_name): + if bin_path.exists(): + link = hermes_lsp_bin_dir() / bin_path.name + if not link.exists(): + try: + link.symlink_to(bin_path) + except (OSError, NotImplementedError): + try: + shutil.copy2(bin_path, link) + except OSError: + return str(bin_path) + return str(link if link.exists() else bin_path) return None diff --git a/tests/agent/lsp/test_install_and_lint_fixes.py b/tests/agent/lsp/test_install_and_lint_fixes.py index e9f862a6d..abbaef94e 100644 --- a/tests/agent/lsp/test_install_and_lint_fixes.py +++ b/tests/agent/lsp/test_install_and_lint_fixes.py @@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch): assert install_targets == ["pyright"] +def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch): + """Installed Windows shims should satisfy later status/probe calls.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from agent.lsp import install as install_mod + + wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd" + wrapper.write_text("@echo off\n") + wrapper.chmod(0o755) + + monkeypatch.setattr(install_mod, "_is_windows", lambda: True) + monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None) + + assert install_mod._existing_binary("pyright-langserver") == str(wrapper) + assert install_mod.detect_status("pyright") == "installed" + + +def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch): + """pip console scripts can land in Scripts/ on native Windows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from agent.lsp import install as install_mod + + def fake_run(cmd, **kwargs): + scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + launcher = scripts_dir / "fake-language-server.exe" + launcher.write_text("launcher\n") + launcher.chmod(0o755) + return MagicMock(returncode=0, stderr="") + + monkeypatch.setattr(install_mod, "_is_windows", lambda: True) + monkeypatch.setattr(install_mod.subprocess, "run", fake_run) + + resolved = install_mod._install_pip("fake-lsp", "fake-language-server") + + assert resolved is not None + assert resolved.endswith("fake-language-server.exe") + assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists() + + # --------------------------------------------------------------------------- # Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash # ---------------------------------------------------------------------------