PowerShell code intelligence for Claude Code,
powered by PowerShell Editor Services
(PSES). Real-time PowerShell diagnostics and PSScriptAnalyzer fix suggestions
while editing .ps1, .psm1, and .psd1 files. Hover, go-to-definition, and
find-references are on the roadmap, pending upstream plugin LSP-server
registration (Claude Code #66987).
This is language tooling, not project tooling: a standalone plugin that carries ~0 always-on model-context token cost. It only spawns a language server when you open a PowerShell file, and a single warm PSES serves the whole session so each edit pays a pipe round-trip (~2 s) instead of a cold start (~6 s).
- PowerShell 7+ (
pwsh) is required. As of 1.1.1 the plugin's hooks launch underpwsh; Windows PowerShell 5.1 alone cannot bootstrap them. Install pwsh from https://aka.ms/powershell or viawinget install Microsoft.PowerShell. - Windows PowerShell 5.1 (
powershell) is still supported as the PSES child host: setps_hosttopowershellto run the language server under 5.1 (the hooks themselves still requirepwsh). - Internet access on first run: PSES is downloaded on first use (not vendored).
Add this repository as a marketplace, then install the plugin:
/plugin marketplace add manderse21/claude-powershell-lsp
/plugin install powershell-lsp@claude-powershell-lsp
The plugin ships disabled by default (defaultEnabled: false) because it
downloads a bundle and spawns a language server. Enable it explicitly:
/plugin enable powershell-lsp
Then start a new session (or /reload-plugins). On the first session with the
plugin enabled, the SessionStart hook bootstraps PSES into your plugin data
directory. Open a .ps1 file to bring the language server up.
Set these via the /plugin config UI for powershell-lsp, or leave the defaults.
| Key | Default | Meaning |
|---|---|---|
ps_host |
pwsh |
Host executable: pwsh (PowerShell 7+, recommended/tested) or powershell (Win 5.1) |
severityThreshold |
Hint |
Least-severe level to report: Error > Warning > Information > Hint |
ruleInclude |
(empty) | Comma-separated PSScriptAnalyzer rule codes to report exclusively; empty = all |
ruleExclude |
(empty) | Comma-separated rule codes to suppress (e.g. PSAvoidUsingWriteHost) |
timeoutMs |
5000 |
Total hard cap (ms) before the PostToolUse client degrades to log-only |
debounceMs |
150 |
Edits landing within this window (ms) fold into one analysis pass |
keepLastN |
10 |
Newest rolling log files kept per family (swept at SessionStart) |
idleTtlMin |
30 |
Daemon self-terminates after this many minutes with no diagnostics request |
perFileCap |
20 |
Max diagnostics reported per file; the rest collapse into an ... and N more line; 0 = no cap |
Diagnostics are returned in a stable order (severity, then line, then column), deduped, threshold- and rule-filtered, then capped per file.
These filters apply on top of whatever PSES publishes. PSES runs its own default
PSScriptAnalyzer rule set for live analysis, which is narrower than the
Invoke-ScriptAnalyzer CLI default -- for example PSAvoidUsingWriteHost is not
surfaced on the fly even though the CLI flags it. The knobs here can suppress or
narrow what PSES reports; they cannot add a rule PSES does not run.
Warm-path daemon (v1.1.0), pwsh 7.6.2, Windows 11. Measured warm-path
latency (median of 5 successive edits): ~2.0 s wall clock per edit
(~1998 ms), versus the ~6 s cold start of a per-edit-spawn predecessor. Roughly
0.7 s of that is the per-hook pwsh process spawn that Claude Code pays
regardless of plugin code.
The acceptance suite confirms: cold-session bring-up launches exactly one daemon; a deliberate diagnostic returns over the warm path; the settled PSScriptAnalyzer pass (not the early parser publish) is reported; file URIs carry uppercase drive letters; three rapid edits coalesce into one analysis pass; SessionEnd leaves no daemon/PSES processes; and killing the daemon mid-session degrades gracefully (no stdout, under the hard cap) while the next SessionStart reaps the stale session and its orphaned PSES.
Diagnostics are delivered through a PostToolUse hook backed by a warm, per-session daemon -- one PSES stays hot for the whole session, so each edit pays a pipe round-trip instead of a cold PSES start.
SessionStart -> scripts/session-start.ps1
ensure-pses.ps1 (idempotent PSES bootstrap, pinned tag)
ensure-pssa.ps1 (idempotent PSScriptAnalyzer vendor, pinned)
log sweep (keep-last-10 per family)
reap OUR stale daemons (recorded pids only, verified)
launch scripts/pses-daemon.ps1 (one warm PSES via -Stdio;
named pipe powershell-lsp-<sessionid>; pid/heartbeat in
CLAUDE_PLUGIN_DATA/session/<sessionid>.json)
PostToolUse -> scripts/lsp-client.ps1
read hook JSON (session_id, file_path) from stdin
connect to the pipe, request diagnostics for the edited file
daemon: didOpen/didChange -> wait for the SETTLED PScriptAnalyzer
publish (not the early parser publish) -> debounce
return deduped, severity-sorted diagnostics to Claude via
hookSpecificOutput.additionalContext
SessionEnd -> scripts/session-end.ps1
pipe {shutdown} -> daemon sends LSP shutdown/exit to PSES,
removes its session file, exits
scripts/lib/lsp-common.ps1: shared helpers (host detection, file-URI with uppercase drive, LSP framing, diagnostics ordering/dedupe), dot-sourced by the daemon, client, hooks, and tests.scripts/ensure-pses.ps1: idempotent PSES bootstrap into${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices; no-op once present.scripts/ensure-pssa.ps1: idempotent vendor of pinned PSScriptAnalyzer into${CLAUDE_PLUGIN_DATA}/modules, prepended to the PSES child'sPSModulePathso the analyzer pass runs (PSES emits only parser errors without it).scripts/pses-stdio.ps1: the cold-start-Stdiolauncher -- the destination for native.lsp.jsonregistration (see below).
All scripts run -NoLogo -NoProfile, write nothing to stdout on the daemon/LSP
path, and keep all state, logs, and pids under CLAUDE_PLUGIN_DATA only.
Claude Code declares plugin language servers through a per-plugin
.lsp.json file
(or an equivalent inline lspServers block, which this plugin's plugin.json
carries). That is the intended path. In practice it has been unreliable for
plugin-provided servers, for two independent reasons:
- Marketplace plugins can install without their
.lsp.json. Claude Code copies a plugin's source directory into its cache; anlspServersblock that lives only inmarketplace.jsonis not written out, so the installed plugin registers 0 servers. Tracked (open) at claude-plugins-official#379. A proposed fix, PR #378 (add a real.lsp.jsonto each official LSP plugin), was closed unmerged (2026-02-11), so #379 remains open and unaddressed. - A registration race.
LspServerManagercan initialize before plugins finish loading, registering 0 servers even when a.lsp.jsonis present. First reported in claude-code#14803 (fixed) and analyzed in detail in claude-code#29858; the symptom remains open at #15168 and #15148.
So rather than depend on native registration, this plugin delivers diagnostics through a warm PostToolUse hook that always works, on every supported host, today. The hook is the product; native registration is a bonus you can opt into.
The plugin already declares its server (the lspServers block in plugin.json), and
a standalone copy ships at docs/lsp.json.template. Both are
the intended native path -- but as of Claude Code 2.1.167 neither activates. This
was confirmed across every configuration on 2026-06-06 (dispatch 000008):
- a clean top-level-map
.lsp.jsonwith literal commands (no${CLAUDE_PLUGIN_ROOT}/${user_config.*}template variables), loaded into a freshly started process (--plugin-dir, a full restart, not/reload-plugins) ->No LSP server available for file type: .ps1; - that same literal
.lsp.jsonshipped inside a throwaway plugin and installed through the real/pluginflow, so the installer placed the file in the plugin cache (the exact installed-cache setup some users report working, reached without hand-writing the cache) -> stillNo LSP server available, after a full restart; - the installed real plugin, whose cache already carries a template-var
.lsp.json-> inert the same way.
So the inertness is not a reload-vs-restart, template-variable, or
--plugin-dir-vs-installed-cache artifact -- a plugin .lsp.json simply does not
register on 2.1.167. This finally tests the installed-cache path a prior re-test had to
leave open, closing that caveat rather than narrowing it. Native registration is not
something this plugin can rely on today -- which is why diagnostics ride the PostToolUse
hook, the path that works on every host now. (Methodology and evidence in
docs/upstream/claude-code-lsp-registration.md,
held for review.)
The template ships as docs/lsp.json.template (not live at the root) on purpose: a
root .lsp.json adds nothing while registration is broken, and would risk duplicate
diagnostics the moment a future release fixes it. When that release lands, copy it in
to opt into the native path:
cp docs/lsp.json.template .lsp.json
# then FULLY restart Claude Code -- a new process. /reload-plugins is not enough:
# the 2026-06-06 re-test confirmed a plugin-root .lsp.json stays inert even after a
# full restart on 2.1.167, so this is for a future release that fixes registration.
Heads-up once it does activate -- duplicate diagnostics. If native registration ever turns on while the PostToolUse hook is also enabled, each diagnostic arrives twice. Use one path or the other.
| Component | Version | Pinned in | Source |
|---|---|---|---|
| PSES | v4.6.0 |
scripts/ensure-pses.ps1 ($PsesTag) |
GitHub release PowerShellEditorServices.zip |
| PSScriptAnalyzer | 1.25.0 |
scripts/ensure-pssa.ps1 ($PssaVersion) |
PowerShell Gallery |
To bump either, change the single pin variable named above and start a fresh session (the ensure-step re-vendors at the new version, keyed by a per-version marker). See CHANGELOG for how a bump maps to SemVer.
As of 1.1.1 the hooks require pwsh (PowerShell 7) -- they launch the bootstrap
under it on every platform. Windows PowerShell 5.1 is supported as the PSES child
host (set ps_host to powershell), not as the hook interpreter.
CI runs the Pester suite on a four-leg matrix: Windows pwsh 7, Windows
PowerShell 5.1, Ubuntu pwsh, and (as of 1.3.0) macOS pwsh. The full
warm-daemon integration suite (one-daemon bring-up, the settled PSScriptAnalyzer
pass, clean SessionEnd) runs and is green on all four legs -- so the Linux and
macOS daemon paths are CI-verified, not merely authored. The integration tests drive the daemon under
pwsh on every leg, so the Windows-PowerShell-5.1 leg's distinct value is exercising
the shared-library surface under 5.1 -- file-URI casing, BOM-tolerant stdin, the
ArgumentList-vs-quoted-.Arguments split, and the config-env fallback -- the code
that must keep working when PSES runs as a 5.1 child.
The scripts are cross-platform: all paths go through Join-Path, host detection is
shared, the single Windows-only call (process command-line lookup, used to verify a
pid is ours before any kill) is guarded behind Test-OnWindows with Linux /proc
and macOS ps fallbacks, and the client/daemon transport is System.IO.Pipes (Unix
domain socket semantics on *nix). As of 1.3.0 that macOS ps fallback is exercised by
the macOS CI integration leg, so macOS is CI-verified alongside Linux.
- Hooks fail with
'pwsh' is not recognized/ pwsh not found: as of 1.1.1 the hooks launch under PowerShell 7. Install it (winget install Microsoft.PowerShell) -- Windows PowerShell 5.1 alone cannot launch the hooks. (ps_hostonly selects the PSES child host, not the hook interpreter.) - A leftover user-level PSES hook fires alongside the plugin (duplicate or
conflicting diagnostics): if you previously wired a PowerShell diagnostics hook
directly in
~/.claude/settings.json(a pre-plugin setup), remove it -- the plugin owns the SessionStart / PostToolUse / SessionEnd hooks now, and a stray user-level hook will double up or conflict with them. /pluginErrors tab showsExecutable not found in $PATHfor thepowershellserver:ps_hostpoints at an executable that is not on PATH. Install PowerShell 7 (pwsh) or setps_hosttopowershell.- No diagnostics / server never starts: confirm the bootstrap ran by checking
that
${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices/PowerShellEditorServices/Start-EditorServices.ps1exists. If not, start a fresh session so theSessionStarthook can run, and inspect${CLAUDE_PLUGIN_DATA}/logs/ensure-pses.log. - Server starts but handshake fails: inspect the PSES log under
${CLAUDE_PLUGIN_DATA}/logs/pses-lsp.log/StartEditorServices-<pid>.logfor the PSES-side error. PrepareRenameHandlerNullReferenceExceptionon initialize: a PSESv4.6.0bug -- its rename handler dereferences a nullRenameCapabilitywhen an LSP client'stextDocumentcapabilities omitrename. This plugin's daemon declares a minimalrenamecapability on purpose, which is what avoids the NRE, so the warm path is unaffected. You would only hit this by driving PSES from a client that omits rename (e.g. a hand-rolled minimal client against the cold-Stdiolauncher); if so, pin PSESv4.5.0inscripts/ensure-pses.ps1($PsesTag), which predates the rename handler.
MIT. See LICENSE.