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 <HERMES_HOME>/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.
This commit is contained in:
Teknium 2026-06-30 16:22:18 -07:00 committed by GitHub
parent 812236bff8
commit 97e0bbef53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 289 additions and 0 deletions

View file

@ -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"},
}

View file

@ -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. ``<HERMES_HOME>/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 "
"<HERMES_HOME>/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)",
),
]

View file

@ -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"

View file

@ -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
`<HERMES_HOME>/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 `<HERMES_HOME>/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