From 97e0bbef53df86f1dfd253b410b3a85539bee2c1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:22:18 -0700 Subject: [PATCH] feat(lsp): add PowerShellEditorServices language server (#55930) Registers PowerShell (.ps1/.psm1/.psd1) in the LSP server registry, spawning PowerShellEditorServices over stdio via a pwsh/powershell host. PSES ships as a GitHub release zip (no npm/go/pip recipe), so it sits in the manual install tier alongside rust-analyzer and clangd. The spawn builder resolves the module bundle from (in order) the lsp.servers.powershell.command override, init bundlePath, the PSES_BUNDLE_PATH env var, or /lsp/PowerShellEditorServices, then launches Start-EditorServices.ps1 -Stdio with a non-interactive, no-profile host. hermes lsp status/list report it as manual-only until pwsh is present. Docs and tests included. --- agent/lsp/install.py | 5 + agent/lsp/servers.py | 147 ++++++++++++++++++++++ tests/agent/lsp/test_powershell_server.py | 114 +++++++++++++++++ website/docs/user-guide/features/lsp.md | 23 ++++ 4 files changed, 289 insertions(+) create mode 100644 tests/agent/lsp/test_powershell_server.py diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 418cc510c..2cba93723 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -102,6 +102,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = { # 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"}, + # PowerShell — PowerShellEditorServices ships as a GitHub release + # zip driven by a pwsh bootstrap script, not a single binary. We + # require a manual bundle install and probe for the pwsh host so + # `hermes lsp status` reports the host's presence. + "powershell": {"strategy": "manual", "pkg": "", "bin": "pwsh"}, } diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index 8ba87be94..4056ba4db 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -102,6 +102,9 @@ LANGUAGE_BY_EXT: Dict[str, str] = { ".zig": "zig", ".zon": "zig", ".dockerfile": "dockerfile", + ".ps1": "powershell", + ".psm1": "powershell", + ".psd1": "powershell", } @@ -676,6 +679,131 @@ def _spawn_astro(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: ) +_PSES_BUNDLE_WARNED = False + + +def _find_pses_bundle(ctx: ServerContext) -> Optional[str]: + """Locate the PowerShellEditorServices module bundle directory. + + PSES ships as a GitHub release zip (not an npm/go/pip package), so + there's no auto-install recipe — the user downloads it and points us + at the extracted bundle. Resolution order: + + 1. ``command`` override in config (``lsp.servers.powershell.command``) — + the FIRST element is treated as the bundle path when it's a + directory. This is the documented config knob. + 2. ``init_overrides["powershell"]["bundlePath"]``. + 3. ``PSES_BUNDLE_PATH`` env var. + 4. ``/lsp/PowerShellEditorServices`` staging dir (where a + user-run unzip would naturally land). + + Returns the bundle directory containing ``PowerShellEditorServices/``, + or ``None`` when it can't be found. + """ + candidates: List[str] = [] + override = ctx.binary_overrides.get("powershell") + if override and override[0]: + candidates.append(override[0]) + init = ctx.init_overrides.get("powershell", {}) + if isinstance(init, dict) and init.get("bundlePath"): + candidates.append(str(init["bundlePath"])) + env_path = os.environ.get("PSES_BUNDLE_PATH") + if env_path: + candidates.append(env_path) + home = os.environ.get("HERMES_HOME") or os.path.join( + os.path.expanduser("~"), ".hermes" + ) + candidates.append(os.path.join(home, "lsp", "PowerShellEditorServices")) + + for cand in candidates: + if not cand: + continue + # Accept either the bundle root or the inner module dir. + start_script = os.path.join( + cand, "PowerShellEditorServices", "Start-EditorServices.ps1" + ) + if os.path.isfile(start_script): + return cand + inner = os.path.join(cand, "Start-EditorServices.ps1") + if os.path.isfile(inner): + return os.path.dirname(cand) + return None + + +def _spawn_powershell_es(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: + """Spawn PowerShellEditorServices over stdio. + + Unlike the single-binary servers, PSES is a PowerShell module driven + by a bootstrap script. We need both a PowerShell host (``pwsh`` for + PowerShell 7+, or Windows ``powershell``) and the PSES module bundle. + The bundle is manual-install (release zip) — see ``_find_pses_bundle``. + """ + pwsh = _which("pwsh", "powershell") + if pwsh is None: + return None + bundle = _find_pses_bundle(ctx) + if bundle is None: + global _PSES_BUNDLE_WARNED + if not _PSES_BUNDLE_WARNED: + _PSES_BUNDLE_WARNED = True + logger.warning( + "powershell: pwsh found but the PowerShellEditorServices " + "bundle is missing. Download the release zip from " + "https://github.com/PowerShell/PowerShellEditorServices/releases, " + "extract it, and either set lsp.servers.powershell.command " + "to the bundle path or unzip it to " + "/lsp/PowerShellEditorServices." + ) + return None + start_script = os.path.join( + bundle, "PowerShellEditorServices", "Start-EditorServices.ps1" + ) + # Session details file: PSES writes connection info here on startup. + session_path = os.path.join( + hermes_lsp_session_dir(), f"pses-session-{os.getpid()}.json" + ) + log_path = os.path.join(hermes_lsp_session_dir(), "pses.log") + inner = ( + f"& '{start_script}' " + f"-BundledModulesPath '{bundle}' " + f"-LogPath '{log_path}' " + f"-SessionDetailsPath '{session_path}' " + f"-FeatureFlags @() -AdditionalModules @() " + f"-HostName Hermes -HostProfileId hermes -HostVersion 1.0.0 " + f"-Stdio -LogLevel Normal" + ) + return SpawnSpec( + command=[ + pwsh, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + inner, + ], + workspace_root=root, + cwd=root, + env=ctx.env_overrides.get("powershell", {}), + initialization_options={ + k: v + for k, v in ctx.init_overrides.get("powershell", {}).items() + if k != "bundlePath" + }, + ) + + +def hermes_lsp_session_dir() -> str: + """Return (and create) the dir for PSES session/log scratch files.""" + home = os.environ.get("HERMES_HOME") or os.path.join( + os.path.expanduser("~"), ".hermes" + ) + d = os.path.join(home, "lsp", "pses") + os.makedirs(d, exist_ok=True) + return d + + def _resolve_override(ctx: ServerContext, server_id: str) -> Optional[str]: """User can pin a binary path in config.""" override = ctx.binary_overrides.get(server_id) @@ -823,6 +951,18 @@ def _root_java(file_path: str, workspace: str) -> Optional[str]: ) +def _root_powershell(file_path: str, workspace: str) -> Optional[str]: + # PowerShell projects rarely have a universal root marker. Use the + # PSScriptAnalyzer settings file when present, otherwise fall back to + # the git workspace root (nearest_root does exact-name matching only, + # so no globs here). + return _root_or_workspace( + file_path, + workspace, + ["PSScriptAnalyzerSettings.psd1"], + ) + + # --------------------------------------------------------------------------- # the registry # --------------------------------------------------------------------------- @@ -1012,6 +1152,13 @@ SERVERS: List[ServerDef] = [ build_spawn=_spawn_jdtls, description="Java — Eclipse JDT Language Server", ), + ServerDef( + server_id="powershell", + extensions=(".ps1", ".psm1", ".psd1"), + resolve_root=_root_powershell, + build_spawn=_spawn_powershell_es, + description="PowerShell — PowerShellEditorServices (manual bundle)", + ), ] diff --git a/tests/agent/lsp/test_powershell_server.py b/tests/agent/lsp/test_powershell_server.py new file mode 100644 index 000000000..9c424cfb0 --- /dev/null +++ b/tests/agent/lsp/test_powershell_server.py @@ -0,0 +1,114 @@ +"""Tests for the PowerShellEditorServices (PSES) server registration. + +PSES is unusual among the registry entries: it's a PowerShell module +bundle (GitHub release zip) driven by a ``pwsh`` bootstrap script, not a +single binary on PATH. These tests cover the registry wiring plus the +two-prerequisite spawn logic (pwsh host + module bundle). +""" +from __future__ import annotations + +import os + +import agent.lsp.servers as srv +from agent.lsp.install import detect_status +from agent.lsp.servers import ( + ServerContext, + find_server_for_file, + language_id_for, +) + + +def test_powershell_extensions_route_to_pses(): + for ext in ("script.ps1", "module.psm1", "manifest.psd1"): + s = find_server_for_file(ext) + assert s is not None, ext + assert s.server_id == "powershell" + + +def test_powershell_language_ids(): + assert language_id_for("a.ps1") == "powershell" + assert language_id_for("a.psm1") == "powershell" + assert language_id_for("a.psd1") == "powershell" + + +def test_powershell_install_status_is_manual_tier(): + # PSES has no npm/go/pip recipe; it's manual-only (like rust-analyzer). + # When pwsh isn't on PATH the status is manual-only, not "missing". + status = detect_status("powershell") + assert status in {"manual-only", "installed"} + + +def test_spawn_skips_when_pwsh_missing(monkeypatch, tmp_path): + monkeypatch.setattr(srv, "_which", lambda *names: None) + ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual") + assert srv._spawn_powershell_es(str(tmp_path), ctx) is None + + +def test_spawn_skips_when_bundle_missing(monkeypatch, tmp_path): + # pwsh present, but no bundle anywhere. + monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh") + monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home")) + ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual") + assert srv._spawn_powershell_es(str(tmp_path), ctx) is None + + +def _make_fake_bundle(root) -> str: + bundle = root / "PowerShellEditorServices" + inner = bundle / "PowerShellEditorServices" + inner.mkdir(parents=True) + (inner / "Start-EditorServices.ps1").write_text("# fake") + return str(bundle) + + +def test_spawn_builds_command_with_bundle_via_env(monkeypatch, tmp_path): + monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home")) + bundle = _make_fake_bundle(tmp_path) + monkeypatch.setenv("PSES_BUNDLE_PATH", bundle) + + ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual") + spec = srv._spawn_powershell_es(str(tmp_path), ctx) + assert spec is not None + assert spec.command[0] == "/usr/bin/pwsh" + assert "-Stdio" in spec.command[-1] + assert "Start-EditorServices.ps1" in spec.command[-1] + assert bundle in spec.command[-1] + # -NonInteractive / -NoProfile keep the host from hanging on a prompt. + assert "-NonInteractive" in spec.command + assert "-NoProfile" in spec.command + + +def test_spawn_prefers_command_override_bundle(monkeypatch, tmp_path): + monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home")) + monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False) + bundle = _make_fake_bundle(tmp_path) + + ctx = ServerContext( + workspace_root=str(tmp_path), + install_strategy="manual", + binary_overrides={"powershell": [bundle]}, + ) + spec = srv._spawn_powershell_es(str(tmp_path), ctx) + assert spec is not None + assert bundle in spec.command[-1] + + +def test_bundle_path_init_override_not_leaked_into_init_options(monkeypatch, tmp_path): + monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home")) + monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False) + bundle = _make_fake_bundle(tmp_path) + + ctx = ServerContext( + workspace_root=str(tmp_path), + install_strategy="manual", + init_overrides={"powershell": {"bundlePath": bundle, "foo": "bar"}}, + ) + spec = srv._spawn_powershell_es(str(tmp_path), ctx) + assert spec is not None + # bundlePath is a Hermes-internal resolution key — it must not be sent + # to the server as an LSP initializationOption. + assert "bundlePath" not in spec.initialization_options + assert spec.initialization_options.get("foo") == "bar" diff --git a/website/docs/user-guide/features/lsp.md b/website/docs/user-guide/features/lsp.md index c0ed863f7..50df34279 100644 --- a/website/docs/user-guide/features/lsp.md +++ b/website/docs/user-guide/features/lsp.md @@ -86,12 +86,35 @@ agent sees a syntax-clean file with semantic problems as | Prisma | `prisma language-server` | manual | | Kotlin | `kotlin-language-server` | manual | | Java | `jdtls` | manual | +| PowerShell | `PowerShellEditorServices` (`pwsh` host) | manual (release zip) | For "manual" entries, install the server through whatever toolchain manager makes sense for that language (rustup, ghcup, opam, brew, …). Hermes auto-detects the binary on PATH or in `/lsp/bin/`. +### PowerShell + +PowerShellEditorServices isn't a single binary — it's a PowerShell +module bundle launched by a `pwsh` (PowerShell 7+) or `powershell` +host. Setup: + +1. Install [PowerShell](https://github.com/PowerShell/PowerShell) so + `pwsh` (or Windows `powershell`) is on PATH. +2. Download the latest release zip from + [PowerShellEditorServices releases](https://github.com/PowerShell/PowerShellEditorServices/releases) + and extract it. +3. Point Hermes at the extracted bundle — the directory that contains + `PowerShellEditorServices/Start-EditorServices.ps1`. Either: + - set `lsp.servers.powershell.command: ["/path/to/bundle"]` in + `config.yaml`, or + - extract it to `/lsp/PowerShellEditorServices`, or + - export `PSES_BUNDLE_PATH=/path/to/bundle`. + +`hermes lsp status` reports `installed` once `pwsh` is found; if the +bundle is missing you'll see a one-time warning in the logs with the +download link. + A few servers are installed alongside a peer dependency that npm won't auto-pull. The current case is `typescript-language-server`, which requires the `typescript` SDK importable from the same