mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Three follow-ups to PR #24168 found during live E2E testing on TS/bash files: 1. typescript-language-server now installs the typescript SDK (tsserver) alongside it. Without that sibling install, initialize() failed with "Could not find a valid TypeScript installation" and the server was marked broken — no diagnostics ever reached the agent. New extra_pkgs field on INSTALL_RECIPES makes that explicit and reusable for future peer-dep cases. 2. _check_lint now treats "linter command exists on PATH but cannot actually run" as skipped instead of error. The motivating case is npx tsc when typescript is not in node_modules — npx prints its "This is not the tsc command you are looking for" banner and exits non-zero, which previously blocked the LSP semantic tier (gated on success or skipped). Pattern-matched per base command (npx, rustfmt, go) so genuine lint errors still flow through normally. 3. hermes lsp status now surfaces a Backend warnings section when bash-language-server is installed but shellcheck is missing. The server itself spawns fine but bash-language-server delegates diagnostics to shellcheck — without it on PATH the integration looks alive but never reports any problems. Same warning is logged once at server spawn time. Validation: - 12 new tests in tests/agent/lsp/test_install_and_lint_fixes.py: * recipe carries typescript SDK * _install_npm passes both pkg + extras to npm CLI * backwards compat: recipes without extras still work * _backend_warnings quiet when bash absent / both present * _backend_warnings fires when bash installed without shellcheck * status output includes the Backend warnings section * _looks_like_linter_unusable catches the npx tsc banner * real TS type errors not misclassified as unusable * unfamiliar linters fall through normally * _check_lint returns skipped on npx tsc unusable * _check_lint returns error on real tsc type errors - Full lsp + file_operations test suite: 245/245 pass - Live E2E: * try_install("typescript-language-server") installs both packages into node_modules * write_file(bad.ts, ...) returns lint=skipped + lsp_diagnostics with two real TS errors (was lint=error, no lsp_diagnostics) * hermes lsp status renders the shellcheck warning when bash is installed but shellcheck is not on PATH
376 lines
13 KiB
Python
376 lines
13 KiB
Python
"""Auto-installation of LSP server binaries.
|
|
|
|
Tries to install missing servers using whatever package manager is
|
|
appropriate. All installs go to a Hermes-owned bin staging dir,
|
|
``<HERMES_HOME>/lsp/bin/``, so we don't pollute the user's global
|
|
toolchain.
|
|
|
|
Strategies:
|
|
|
|
- ``auto`` — attempt to install with the best available package
|
|
manager. This is the default.
|
|
- ``manual`` — never install; if a binary is missing, the server is
|
|
silently skipped and the user is told about it via ``hermes lsp
|
|
status``.
|
|
- ``off`` — same as ``manual`` for now (kept distinct so we can
|
|
evolve behavior later, e.g. logging differently).
|
|
|
|
The actual installs happen synchronously the first time a server is
|
|
needed and concurrent calls to :func:`try_install` for the same
|
|
package are deduplicated via a per-package lock.
|
|
|
|
Failure modes are non-fatal: every install path is wrapped in
|
|
try/except and returns ``None`` on failure. The tool layer then
|
|
falls back to its in-process syntax checker, exactly as if the user
|
|
hadn't enabled LSP at all.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
logger = logging.getLogger("agent.lsp.install")
|
|
|
|
# Package-name → install-strategy hint registry. Each entry is a
|
|
# tuple of strategy name + package name + executable name. When the
|
|
# install completes, we look for the executable in
|
|
# ``<HERMES_HOME>/lsp/bin/`` first, then on PATH.
|
|
#
|
|
# Optional fields:
|
|
# - ``extra_pkgs``: list of sibling packages to install alongside
|
|
# ``pkg`` in the same node_modules tree. Used when an LSP server
|
|
# has a runtime peer dependency that npm doesn't auto-pull (e.g.
|
|
# typescript-language-server needs ``typescript``).
|
|
INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
|
# Python
|
|
"pyright": {"strategy": "npm", "pkg": "pyright", "bin": "pyright-langserver"},
|
|
# JS/TS family
|
|
"typescript-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "typescript-language-server",
|
|
"bin": "typescript-language-server",
|
|
# typescript-language-server requires the `typescript` SDK
|
|
# (tsserver) to be importable from the same node_modules tree;
|
|
# otherwise initialize() fails with "Could not find a valid
|
|
# TypeScript installation". Install them together.
|
|
"extra_pkgs": ["typescript"],
|
|
},
|
|
"@vue/language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "@vue/language-server",
|
|
"bin": "vue-language-server",
|
|
},
|
|
"svelte-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "svelte-language-server",
|
|
"bin": "svelteserver",
|
|
},
|
|
"@astrojs/language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "@astrojs/language-server",
|
|
"bin": "astro-ls",
|
|
},
|
|
"yaml-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "yaml-language-server",
|
|
"bin": "yaml-language-server",
|
|
},
|
|
"bash-language-server": {
|
|
"strategy": "npm",
|
|
"pkg": "bash-language-server",
|
|
"bin": "bash-language-server",
|
|
},
|
|
"intelephense": {"strategy": "npm", "pkg": "intelephense", "bin": "intelephense"},
|
|
"dockerfile-language-server-nodejs": {
|
|
"strategy": "npm",
|
|
"pkg": "dockerfile-language-server-nodejs",
|
|
"bin": "docker-langserver",
|
|
},
|
|
# Go
|
|
"gopls": {"strategy": "go", "pkg": "golang.org/x/tools/gopls@latest", "bin": "gopls"},
|
|
# Rust — too heavy (hundreds of MB to bootstrap). We do NOT
|
|
# auto-install rust-analyzer; users install via rustup.
|
|
"rust-analyzer": {"strategy": "manual", "pkg": "", "bin": "rust-analyzer"},
|
|
# C/C++ — manual (clangd ships with LLVM, very heavy)
|
|
"clangd": {"strategy": "manual", "pkg": "", "bin": "clangd"},
|
|
# Lua — manual (LuaLS is platform-specific binaries from GitHub
|
|
# releases; complex enough that we punt to the user)
|
|
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
|
|
}
|
|
|
|
|
|
_install_locks: Dict[str, threading.Lock] = {}
|
|
_install_results: Dict[str, Optional[str]] = {}
|
|
_install_lock_meta = threading.Lock()
|
|
|
|
|
|
def hermes_lsp_bin_dir() -> Path:
|
|
"""Return the Hermes-owned bin staging dir for LSP servers."""
|
|
home = os.environ.get("HERMES_HOME")
|
|
if home is None:
|
|
home = os.path.join(os.path.expanduser("~"), ".hermes")
|
|
p = Path(home) / "lsp" / "bin"
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
|
|
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)
|
|
on_path = shutil.which(name)
|
|
if on_path:
|
|
return on_path
|
|
return None
|
|
|
|
|
|
def _get_lock(pkg: str) -> threading.Lock:
|
|
with _install_lock_meta:
|
|
lock = _install_locks.get(pkg)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
_install_locks[pkg] = lock
|
|
return lock
|
|
|
|
|
|
def try_install(pkg: str, strategy: str = "auto") -> Optional[str]:
|
|
"""Try to install ``pkg`` and return the binary path if successful.
|
|
|
|
``strategy`` is ``"auto"``, ``"manual"``, or ``"off"``. In
|
|
``manual``/``off`` mode, this function only probes for an
|
|
existing binary and returns ``None`` if not found.
|
|
|
|
The install is cached per-package — a second call returns the
|
|
same path (or ``None``) without reinstalling. Concurrent calls
|
|
are serialized.
|
|
"""
|
|
if strategy not in ("auto",):
|
|
# Only ``auto`` triggers an actual install. In manual/off,
|
|
# we still check whether the binary already exists.
|
|
recipe = INSTALL_RECIPES.get(pkg, {})
|
|
bin_name = recipe.get("bin", pkg)
|
|
return _existing_binary(bin_name)
|
|
|
|
if pkg in _install_results:
|
|
return _install_results[pkg]
|
|
|
|
lock = _get_lock(pkg)
|
|
with lock:
|
|
# Double-check after acquiring lock.
|
|
if pkg in _install_results:
|
|
return _install_results[pkg]
|
|
result = _do_install(pkg)
|
|
_install_results[pkg] = result
|
|
return result
|
|
|
|
|
|
def _do_install(pkg: str) -> Optional[str]:
|
|
recipe = INSTALL_RECIPES.get(pkg)
|
|
if recipe is None:
|
|
# Not in our registry — best-effort: just probe PATH.
|
|
return shutil.which(pkg)
|
|
|
|
strategy = recipe.get("strategy", "manual")
|
|
bin_name = recipe.get("bin", pkg)
|
|
|
|
# Check if already present (shutil.which or staging dir)
|
|
existing = _existing_binary(bin_name)
|
|
if existing:
|
|
return existing
|
|
|
|
if strategy == "manual":
|
|
logger.debug("[install] %s requires manual install (recipe=%s)", pkg, recipe)
|
|
return None
|
|
|
|
if strategy == "npm":
|
|
return _install_npm(
|
|
recipe.get("pkg", pkg),
|
|
bin_name,
|
|
extra_pkgs=recipe.get("extra_pkgs") or [],
|
|
)
|
|
if strategy == "go":
|
|
return _install_go(recipe.get("pkg", pkg), bin_name)
|
|
if strategy == "pip":
|
|
return _install_pip(recipe.get("pkg", pkg), bin_name)
|
|
|
|
logger.warning("[install] unknown strategy %r for %s", strategy, pkg)
|
|
return None
|
|
|
|
|
|
def _install_npm(
|
|
pkg: str,
|
|
bin_name: str,
|
|
extra_pkgs: Optional[list] = None,
|
|
) -> Optional[str]:
|
|
"""Install an npm package into our staging dir.
|
|
|
|
Uses ``npm install --prefix`` so the binaries land in
|
|
``<staging>/node_modules/.bin/<bin_name>`` and we symlink them up
|
|
one level for direct PATH-style access.
|
|
|
|
``extra_pkgs`` is a list of sibling packages to install in the
|
|
same ``node_modules`` tree. Used for LSP servers with runtime
|
|
peer deps that npm doesn't auto-pull (typescript-language-server
|
|
needs ``typescript`` next to it; intelephense ships standalone).
|
|
"""
|
|
npm = shutil.which("npm")
|
|
if npm is None:
|
|
logger.info("[install] cannot install %s: npm not on PATH", pkg)
|
|
return None
|
|
staging = hermes_lsp_bin_dir().parent # <HERMES_HOME>/lsp/
|
|
install_targets = [pkg] + list(extra_pkgs or [])
|
|
try:
|
|
logger.info(
|
|
"[install] npm install --prefix %s %s",
|
|
staging,
|
|
" ".join(install_targets),
|
|
)
|
|
proc = subprocess.run(
|
|
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] npm install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
|
)
|
|
return None
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
logger.warning("[install] npm install errored for %s: %s", pkg, e)
|
|
return None
|
|
|
|
# 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:
|
|
if c.exists():
|
|
# Symlink into our `lsp/bin/` for stable PATH access.
|
|
link = hermes_lsp_bin_dir() / c.name
|
|
if not link.exists():
|
|
try:
|
|
link.symlink_to(c)
|
|
except (OSError, NotImplementedError):
|
|
# Symlinks fail on some Windows setups — copy instead.
|
|
try:
|
|
shutil.copy2(c, link)
|
|
except OSError:
|
|
return str(c)
|
|
return str(link if link.exists() else c)
|
|
logger.warning("[install] npm install for %s succeeded but bin %s not found", pkg, bin_name)
|
|
return None
|
|
|
|
|
|
def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
|
"""Install a Go module to GOBIN=<staging>."""
|
|
go = shutil.which("go")
|
|
if go is None:
|
|
logger.info("[install] cannot install %s: go not on PATH", pkg)
|
|
return None
|
|
staging = hermes_lsp_bin_dir()
|
|
env = dict(os.environ)
|
|
env["GOBIN"] = str(staging)
|
|
try:
|
|
logger.info("[install] go install %s (GOBIN=%s)", pkg, staging)
|
|
proc = subprocess.run(
|
|
[go, "install", pkg],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600,
|
|
env=env,
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] go install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
|
)
|
|
return None
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
|
return None
|
|
bin_path = staging / bin_name
|
|
if os.name == "nt":
|
|
bin_path = bin_path.with_suffix(".exe")
|
|
if bin_path.exists():
|
|
return str(bin_path)
|
|
logger.warning("[install] go install for %s succeeded but bin %s not found", pkg, bin_name)
|
|
return None
|
|
|
|
|
|
def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|
"""Install a Python package into a hermes-owned target dir.
|
|
|
|
We avoid polluting the user's site-packages by using
|
|
``pip install --target``. Bins go into
|
|
``<staging>/python-packages/bin/`` which we symlink into
|
|
``<staging>/bin``. Note: this only works for packages that ship a
|
|
console script.
|
|
"""
|
|
pip_target = hermes_lsp_bin_dir().parent / "python-packages"
|
|
pip_target.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.warning(
|
|
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
|
)
|
|
return None
|
|
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)
|
|
return None
|
|
|
|
|
|
def detect_status(pkg: str) -> str:
|
|
"""Return ``installed``, ``missing``, or ``manual-only`` for a package.
|
|
|
|
Used by the ``hermes lsp status`` CLI to give users a quick
|
|
overview of what's available without spawning anything.
|
|
"""
|
|
recipe = INSTALL_RECIPES.get(pkg)
|
|
bin_name = recipe.get("bin", pkg) if recipe else pkg
|
|
if _existing_binary(bin_name):
|
|
return "installed"
|
|
if recipe and recipe.get("strategy") == "manual":
|
|
return "manual-only"
|
|
return "missing"
|
|
|
|
|
|
__all__ = [
|
|
"INSTALL_RECIPES",
|
|
"try_install",
|
|
"detect_status",
|
|
"hermes_lsp_bin_dir",
|
|
]
|