Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions agent/lsp/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@
# 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"},
}


Expand Down
147 changes: 147 additions & 0 deletions agent/lsp/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@
".zig": "zig",
".zon": "zig",
".dockerfile": "dockerfile",
".ps1": "powershell",
".psm1": "powershell",
".psd1": "powershell",
}


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1012,6 +1152,13 @@ def _root_java(file_path: str, workspace: str) -> Optional[str]:
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)",
),
]


Expand Down
114 changes: 114 additions & 0 deletions tests/agent/lsp/test_powershell_server.py
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions website/docs/user-guide/features/lsp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading