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
7 changes: 4 additions & 3 deletions docs/src/content/docs/consumer/install-lsp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,10 @@ See the [Lockfile specification](../../reference/lockfile-spec/).

When APM installs a plugin that contains `lspServers` in `plugin.json`
or a `.lsp.json` file, the LSP servers are automatically extracted and
wired into the install pipeline. The `${CLAUDE_PLUGIN_ROOT}` placeholder
in server configs is replaced with the absolute plugin path for legacy
Claude Code plugin compatibility.
wired into the install pipeline. Plugin `.lsp.json` files may use either
a flat server map or a `{ "lspServers": { ... } }` envelope. The
`${CLAUDE_PLUGIN_ROOT}` placeholder in server configs is replaced with
the absolute plugin path for legacy Claude Code plugin compatibility.

## Runtime support

Expand Down
3 changes: 2 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,8 @@ Optional fields: `args`, `transport`, `env`, `initializationOptions`,
Claude Code uses `.lsp.json` or `~/.claude.json`, and GitHub Copilot CLI
uses `.github/lsp.json` or `~/.copilot/lsp-config.json`. Copilot CLI
uses `fileExtensions` on disk; manifests continue to use
`extensionToLanguage`.
`extensionToLanguage`. Plugin `.lsp.json` files may use either a flat
server map or a `{ "lspServers": { ... } }` envelope.

## Version pinning

Expand Down
22 changes: 20 additions & 2 deletions src/apm_cli/deps/plugin_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,13 @@ def _read_lsp_file(plugin_path: Path, rel_path: str, logger: logging.Logger) ->
def _read_lsp_json(path: Path, logger: logging.Logger) -> dict[str, Any]:
"""Parse a JSON file and return the LSP servers mapping.

Unlike .mcp.json which has a wrapper key, .lsp.json uses server names
as top-level keys directly.
Accepts two formats:
- Flat: top-level keys are server names (e.g. ``{"pyright": {...}}``).
- Wrapped: a ``"lspServers"`` envelope wraps the servers
(e.g. ``{"lspServers": {"pyright": {...}}}``).

The wrapped format is standard in Copilot ``.github/lsp.json`` and
Claude ``~/.claude.json``. Plugins may ship either variant.
"""
try:
data = json.loads(path.read_text(encoding="utf-8"))
Expand All @@ -492,6 +497,19 @@ def _read_lsp_json(path: Path, logger: logging.Logger) -> dict[str, Any]:
return {}
if not isinstance(data, dict):
return {}
# Unwrap the { "lspServers": { ... } } envelope when present.
# Only unwrap when the inner value looks like a server *map* (all values
# are dicts). A flat-format server literally named "lspServers" would
# have scalar values like "command", so the all-dicts check avoids
# mis-detecting it as an envelope.
lsp_inner = data.get("lspServers")
if (
isinstance(lsp_inner, dict)
and lsp_inner
and all(isinstance(v, dict) for v in lsp_inner.values())
):
logger.debug("Unwrapped lspServers envelope in %s", path)
return dict(lsp_inner)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return dict(data)


Expand Down
133 changes: 127 additions & 6 deletions tests/unit/deps/test_plugin_parser_lsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
from __future__ import annotations

import json
import logging

import yaml

from apm_cli.deps.plugin_parser import (
_extract_lsp_servers,
_lsp_servers_to_apm_deps,
_read_lsp_json,
synthesize_apm_yml_from_plugin,
)

# ===========================================================================
Expand All @@ -25,29 +29,75 @@ def test_reads_valid_json(self, tmp_path, caplog):
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text(json.dumps({"pyright": {"command": "pyright-langserver"}}))

import logging

result = _read_lsp_json(lsp_file, logging.getLogger("test"))
assert "pyright" in result

def test_returns_empty_on_invalid_json(self, tmp_path, caplog):
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text("not valid json{")

import logging

result = _read_lsp_json(lsp_file, logging.getLogger("test"))
assert result == {}

def test_returns_empty_on_non_dict_json(self, tmp_path):
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text(json.dumps(["not", "a", "dict"]))

import logging

result = _read_lsp_json(lsp_file, logging.getLogger("test"))
assert result == {}

def test_unwraps_lsp_servers_envelope_without_warning(self, tmp_path, caplog):
"""A .lsp.json using { "lspServers": { ... } } is unwrapped without warnings."""
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text(
json.dumps(
{
"lspServers": {
"my-lsp": {
"command": "my-lsp-bin",
"extensionToLanguage": {".ext": "mylang"},
}
}
}
)
)

with caplog.at_level(logging.WARNING, logger="test"):
result = _read_lsp_json(lsp_file, logging.getLogger("test"))

assert "my-lsp" in result
assert result["my-lsp"]["command"] == "my-lsp-bin"
assert "lspServers" not in result
assert not caplog.records

def test_flat_format_still_works(self, tmp_path):
"""Flat format (server names as top-level keys) is unchanged."""
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text(json.dumps({"pyright": {"command": "pyright-langserver"}}))

result = _read_lsp_json(lsp_file, logging.getLogger("test"))
assert "pyright" in result
assert result["pyright"]["command"] == "pyright-langserver"

Comment thread
sergio-sisternes-epam marked this conversation as resolved.
def test_flat_server_named_lspservers_not_unwrapped(self, tmp_path):
"""A flat-format server literally named 'lspServers' must not be mis-detected as an envelope."""
lsp_file = tmp_path / ".lsp.json"
lsp_file.write_text(
json.dumps(
{
"lspServers": {
"command": "my-lsp",
"extensionToLanguage": {".py": "python"},
}
}
)
)

result = _read_lsp_json(lsp_file, logging.getLogger("test"))
# Should keep "lspServers" as a server name, not unwrap it
assert "lspServers" in result
assert result["lspServers"]["command"] == "my-lsp"


# ===========================================================================
# _extract_lsp_servers
Expand Down Expand Up @@ -94,6 +144,29 @@ def test_auto_discovery_of_lsp_json(self, tmp_path):
result = _extract_lsp_servers(tmp_path, manifest)
assert "ts-lsp" in result

def test_auto_discovery_with_lsp_servers_wrapper(self, tmp_path):
"""Auto-discovered .lsp.json using the { "lspServers": ... } envelope."""
lsp_json = tmp_path / ".lsp.json"
lsp_json.write_text(
json.dumps(
{
"lspServers": {
"my-lsp-server": {
"command": "my-lsp",
"args": ["--stdio"],
"extensionToLanguage": {".ext": "mylang"},
}
}
}
)
)

manifest = {} # No lspServers key -- triggers auto-discovery
result = _extract_lsp_servers(tmp_path, manifest)
assert "my-lsp-server" in result
assert result["my-lsp-server"]["command"] == "my-lsp"
assert "lspServers" not in result

def test_no_lsp_servers_no_file_returns_empty(self, tmp_path):
result = _extract_lsp_servers(tmp_path, {})
assert result == {}
Expand Down Expand Up @@ -211,3 +284,51 @@ def test_all_fields_copied(self, tmp_path):
assert d["startupTimeout"] == 5000
assert d["restartOnCrash"] is True
assert d["maxRestarts"] == 3

def test_wrapped_lsp_json_produces_valid_deps(self, tmp_path):
"""End-to-end: .lsp.json with lspServers wrapper yields valid deps."""
lsp_json = tmp_path / ".lsp.json"
lsp_json.write_text(
json.dumps(
{
"lspServers": {
"my-lsp-server": {
"command": "my-lsp",
"args": ["--stdio"],
"extensionToLanguage": {".ext": "mylang"},
}
}
}
)
)
servers = _extract_lsp_servers(tmp_path, {})
deps = _lsp_servers_to_apm_deps(servers, tmp_path)
assert len(deps) == 1
assert deps[0]["name"] == "my-lsp-server"
assert deps[0]["command"] == "my-lsp"

def test_wrapped_lsp_json_is_written_to_apm_yml_deps(self, tmp_path):
"""Wrapped .lsp.json becomes dependencies.lsp in synthesized apm.yml."""
plugin_dir = tmp_path / "plugin"
plugin_dir.mkdir()
(plugin_dir / ".lsp.json").write_text(
json.dumps(
{
"lspServers": {
"my-lsp-server": {
"command": "my-lsp",
"args": ["--stdio"],
"extensionToLanguage": {".ext": "mylang"},
}
}
}
)
)

apm_yml = synthesize_apm_yml_from_plugin(plugin_dir, {"name": "wrapped-lsp"})
parsed = yaml.safe_load(apm_yml.read_text())

lsp_deps = parsed["dependencies"]["lsp"]
assert len(lsp_deps) == 1
assert lsp_deps[0]["name"] == "my-lsp-server"
assert lsp_deps[0]["command"] == "my-lsp"
Loading