vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}

This commit is contained in:
YeonGyu-Kim
2026-05-25 22:24:37 +09:00
parent 06c86f526a
commit 2415f37bc0
260 changed files with 22715 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# codex-plugins
Local marketplace for the `omo` Codex plugin components ported from `../pi-extensions`.
## Plugin
`omo` is one Codex plugin namespace with isolated internal components:
- `components/comment-checker`: runs comment-checker automatically after successful `apply_patch` edits.
- `components/rules`: injects local project rule files into Codex context through lifecycle hooks.
- `components/lsp`: exposes Language Server Protocol diagnostics, navigation, symbols, and rename tools through MCP and post-edit hooks.
- `components/ultrawork`: injects the ultrawork orchestration directive when a user prompt contains `ultrawork` or `ulw`.
- `components/ultragoal`: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit (`.omo/ultragoal/`).
## Local Install
```bash
codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins
node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins
```
The installer builds `omo`, copies a clean versioned cache entry into `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo`, installs runtime dependencies in the cache, prunes stale split-plugin cache/config entries, and enables `[plugins."omo@code-yeongyu-codex-plugins"]` in `~/.codex/config.toml`.
It also enables both `plugins = true` and `plugin_hooks = true` under `[features]` so bundled hook files run.
If your local Codex build exposes plugin install commands, you can use those instead. For older local builds, the installer replaces the manual copy fallback:
```text
~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0
```
+17
View File
@@ -0,0 +1,17 @@
{
"name": "code-yeongyu-codex-plugins",
"interface": {
"displayName": "Yeongyu Codex Plugins"
},
"plugins": [
{
"name": "omo",
"source": "./plugins/omo",
"category": "Developer Tools",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
}
}
]
}
@@ -0,0 +1,35 @@
{
"name": "omo",
"version": "0.1.0",
"description": "One Codex plugin namespace for Yeongyu's local Codex components.",
"author": {
"name": "Yeongyu Kim",
"email": "yeongyu@users.noreply.github.com",
"url": "https://github.com/code-yeongyu"
},
"homepage": "https://github.com/code-yeongyu/omo",
"repository": "https://github.com/code-yeongyu/omo",
"license": "MIT",
"keywords": ["codex", "codex-plugin", "omo", "hooks", "mcp", "skills"],
"skills": "./skills/",
"hooks": "./hooks/hooks.json",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "OMO",
"shortDescription": "Unified local Codex components",
"longDescription": "OMO exposes the local Codex Rules, Comment Checker, LSP, Ultrawork, and Ultragoal components as one plugin namespace while keeping each component isolated under components/ for maintenance.",
"developerName": "Yeongyu Kim",
"category": "Developer Tools",
"capabilities": ["Hooks", "MCP Tools", "Code Intelligence", "Workflow", "Context Injection"],
"websiteURL": "https://github.com/code-yeongyu/omo",
"privacyPolicyURL": "https://github.com/code-yeongyu/omo#privacy",
"termsOfServiceURL": "https://github.com/code-yeongyu/omo#license",
"defaultPrompt": [
"Use OMO LSP diagnostics on this workspace.",
"Show which OMO rules matched this file.",
"ulw: run this change with evidence."
],
"brandColor": "#7C3AED",
"screenshots": []
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"lsp": {
"command": "node",
"args": ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"],
"cwd": "."
}
}
}
+13
View File
@@ -0,0 +1,13 @@
# omo
`omo` is the single local Codex plugin namespace for Yeongyu's Codex components.
Internally each component remains isolated under `components/`:
- `components/comment-checker`
- `components/rules`
- `components/lsp`
- `components/ultrawork`
- `components/ultragoal`
The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and the LSP MCP server.
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1,12 @@
* @code-yeongyu
.github/workflows/* @code-yeongyu
.github/dependabot.yml @code-yeongyu
package.json @code-yeongyu
package-lock.json @code-yeongyu
LICENSE @code-yeongyu
NOTICE @code-yeongyu
README.md @code-yeongyu
CHANGELOG.md @code-yeongyu
.codex-plugin/plugin.json @code-yeongyu
hooks/hooks.json @code-yeongyu
@@ -0,0 +1,40 @@
name: Bug Report
description: Report broken Codex hook, MCP, or comment-checking behavior
labels: [bug]
body:
- type: markdown
attributes:
value: |
Include the Codex tool payload, hook output, and plugin version needed to reproduce.
- type: textarea
id: what
attributes:
label: What happened?
description: Include exact output/errors.
validations:
required: true
- type: textarea
id: payload
attributes:
label: Tool payload
description: Paste the minimal PostToolUse or MCP payload that reproduces the issue.
render: json
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: input
id: version
attributes:
label: codex-comment-checker version
placeholder: 0.1.0
validations:
required: false
@@ -0,0 +1,27 @@
name: Feature Request
description: Propose a Codex comment-checker hook or MCP improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow is blocked or awkward today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-comment-checker do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What else could solve this?
validations:
required: false
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
dev-dependencies:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -0,0 +1,19 @@
## Summary
<!-- Brief description, 1-3 bullets -->
-
## Verification
- [ ] `npm run check` (typecheck + biome + build)
- [ ] `npm test` (unit tests)
- [ ] `npm pack --dry-run` (release sanity)
- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use`
## Codex plugin impact
- [ ] `.codex-plugin/plugin.json` remains valid
- [ ] `hooks/hooks.json` still uses stable Codex hook JSON
- [ ] No MCP server or MCP tool is exposed
- [ ] CHANGELOG entry added for user-facing changes
@@ -0,0 +1,47 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,51 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
.env
.env.*
coverage/
.vitest/
@@ -0,0 +1,35 @@
# Repository Conventions
Conventions for human contributors and AI agents working on this repository.
## Style
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
- TypeScript strict mode. No `any`, no `unknown` casts where avoidable, no `@ts-ignore`, no `@ts-expect-error`, no enums.
- ESM modules with `.js` suffix in runtime import paths.
- Tabs for indentation. Double quotes for strings.
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
## Commands
- `npm install` - install dependencies.
- `npm test` - run vitest once.
- `npm run typecheck` - strict TypeScript check.
- `npm run check` - type check, biome, and build.
- `npm pack --dry-run` - release package smoke test.
- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test the Codex hook.
## Constraints
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
- Keep Codex `PostToolUse` hook behavior covered by tests.
- Keep `apply_patch` extraction covered by tests.
- `apply_patch` must support Codex `tool_input.command`, raw patch text, and OMO-compatible metadata.
- Hook output must use the stable Codex hook JSON contract.
- Do not expose an MCP server or MCP tool from this plugin.
## Don'ts
- No `git add -A` or `git add .`. Stage only the files you changed.
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
- Do not couple this package back to pi, omo, or senpi internal source paths.
@@ -0,0 +1,33 @@
# Changelog
## Unreleased
### Added
- Restore `write`, `edit`, `multi_edit`, and `multiedit` PostToolUse coverage alongside `apply_patch`.
- Forward Codex `transcript_path` into native comment-checker hook input when available.
- Add package smoke coverage for portable hook entrypoints.
### Changed
- Treat the native checker binary as an optional dependency for unsupported platforms.
- Cap child process stdout/stderr captured from the native checker.
- Run CI on Windows in addition to Ubuntu and macOS.
## [0.1.1] - 2026-05-15
### Changed
- Limit automatic comment checking to successful `apply_patch` hook events.
- Remove the `comment_check` MCP tool and MCP server configuration.
- Update plugin metadata, docs, and contributor guidance to describe hook-only behavior.
## [0.1.0] - 2026-05-15
### Added
- Initial `codex-comment-checker` Codex plugin.
- `PostToolUse` hook for `apply_patch`, `write`, `edit`, and `multiedit` style tool calls.
- Blocking hook feedback when `comment-checker` reports warnings.
- `comment_check` MCP tool for explicit write/edit/multiedit checks.
- Codex plugin manifest, local MCP config, bundled skill, and GitHub repository metadata.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,6 @@
codex-comment-checker
This package ports the pi-comment-checker hook into a Codex plugin repository.
The plugin targets Codex plugin manifests and plugin-bundled lifecycle hooks.
The checker engine is provided by @code-yeongyu/comment-checker.
@@ -0,0 +1,90 @@
# codex-comment-checker
[![ci](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Codex plugin that runs [`@code-yeongyu/comment-checker`](https://github.com/code-yeongyu/go-claude-code-comment-checker) after successful edit-like `PostToolUse` hook calls.
## Behavior
| Case | Result |
|------|--------|
| `apply_patch` succeeds | parses `tool_input.command` and checks added/updated files |
| `write`, `edit`, `multi_edit`, or `multiedit` succeeds | maps the Codex payload to the native checker hook input |
| non-edit tool succeeds | ignored |
| checker exits `2` | returns Codex `PostToolUse` blocking feedback so the model fixes or explains the warning |
| checker binary missing or unavailable on the current platform | emits no hook output |
| checker exits unexpectedly | leaves hook output unchanged |
Deletes are ignored because they cannot introduce new comments.
## Codex Plugin
The plugin ships:
- `.codex-plugin/plugin.json` for Codex plugin discovery.
- `hooks/hooks.json` for the `PostToolUse` hook.
- `skills/comment-checker/SKILL.md` with usage guidance.
The hook command is:
```bash
node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
```
No MCP server or `comment_check` tool is exposed.
## Local Development
```bash
npm install
npm test
npm run typecheck
npm run check
npm pack --dry-run
```
Smoke-test the hook:
```bash
node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json
```
## Local Codex Installation
From the marketplace root containing this plugin:
```bash
codex plugin marketplace add /path/to/codex-plugins
node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins
```
If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache/<marketplace>/omo/0.1.0`, installs runtime dependencies there, and enables:
```toml
[features]
plugins = true
plugin_hooks = true
[plugins."omo@code-yeongyu-codex-plugins"]
enabled = true
```
## Branch Rules and Releases
- `main` is protected by `.github/branch-ruleset.json`.
- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows.
- Releases are GitHub Releases tagged as `v<semver>`.
- Publishing runs from the `publish` workflow after a GitHub Release is published.
## Privacy
This plugin runs locally. It sends hook input to the optional local `comment-checker` binary when available and does not call a network service by itself.
## License
[MIT](LICENSE).
## Related
- [pi-comment-checker](https://github.com/code-yeongyu/pi-comment-checker) - source extension this Codex plugin ports.
- [comment-checker](https://github.com/code-yeongyu/go-claude-code-comment-checker) - native checker binary.
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
"timeout": 30,
"statusMessage": "checking comments"
}
]
}
]
}
}
@@ -0,0 +1,57 @@
{
"name": "@code-yeongyu/codex-comment-checker",
"version": "0.1.1",
"description": "Codex plugin that runs comment-checker after edit-like PostToolUse hooks.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/codex-comment-checker",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/codex-comment-checker.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/codex-comment-checker/issues"
},
"keywords": [
"codex",
"codex-plugin",
"comment-checker",
"hooks",
"typescript"
],
"bin": {
"codex-comment-checker": "./dist/cli.js"
},
"files": [
"dist",
"hooks",
"skills",
".codex-plugin",
"LICENSE",
"NOTICE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"check": "tsc --noEmit && biome check . && npm run build"
},
"optionalDependencies": {
"@code-yeongyu/comment-checker": "^0.8.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,16 @@
---
name: comment-checker
description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook.
---
# Codex Comment Checker
The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls.
When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on.
## Scope
- No MCP tool is exposed.
- Non-edit tools are ignored by this plugin.
- Missing checker binaries emit no hook output so normal Codex work can continue.
@@ -0,0 +1,12 @@
#!/usr/bin/env node
import { runCodexHookCli } from "./codex-hook.js";
const [command, subcommand] = process.argv.slice(2);
if (command === "hook" && subcommand === "post-tool-use") {
await runCodexHookCli();
} else {
process.stderr.write("Usage: codex-comment-checker hook post-tool-use\n");
process.exitCode = 2;
}
@@ -0,0 +1,159 @@
import { stdin as processStdin, stdout as processStdout } from "node:process";
import {
type CommentCheckRequest,
extractCommentCheckRequests,
isRecord,
type ToolResultContent,
type ToolResultLike,
toHookInput,
} from "./core.js";
import { type CommentCheckerRunner, runCommentChecker } from "./runner.js";
export type CodexPostToolUseInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "PostToolUse";
model: string;
permission_mode: string;
tool_name: string;
tool_input: Record<string, unknown>;
tool_response: unknown;
tool_use_id: string;
};
export type CodexHookOptions = {
run?: CommentCheckerRunner;
};
export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] {
return extractCommentCheckRequests(toToolResultLike(input));
}
export async function runCommentCheckerPostToolUse(
input: CodexPostToolUseInput,
options: CodexHookOptions = {},
): Promise<string> {
const requests = extractCodexCommentCheckRequests(input);
if (requests.length === 0) return "";
const runner = options.run ?? runCommentChecker;
const warnings: Array<{ filePath: string; message: string }> = [];
for (const request of requests) {
const context = {
sessionId: input.session_id,
cwd: input.cwd,
...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }),
};
const result = await runner(toHookInput(request, context));
if (result.status === "missing" || result.status === "pass") continue;
if (result.status === "error") continue;
const message = result.message.trim();
if (message.length > 0) {
warnings.push({ filePath: request.filePath, message });
}
}
if (warnings.length === 0) return "";
return JSON.stringify({
decision: "block",
reason: formatWarnings(warnings),
});
}
export async function runCodexHookCli(): Promise<void> {
const input = await readStdin();
if (input.trim().length === 0) return;
const parsed = parseCodexPostToolUseInput(input);
if (!parsed) return;
const output = await runCommentCheckerPostToolUse(parsed);
if (output.length > 0) {
processStdout.write(output);
processStdout.write("\n");
}
}
export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return undefined;
}
return isCodexPostToolUseInput(parsed) ? parsed : undefined;
}
function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike {
return {
toolName: input.tool_name,
input: normalizeToolInput(input.tool_name, input.tool_input),
content: normalizeToolResponse(input.tool_response),
isError: isErrorResponse(input.tool_response),
details: isRecord(input.tool_response) ? input.tool_response : undefined,
};
}
function normalizeToolInput(toolName: string, toolInput: Record<string, unknown>): Record<string, unknown> {
if (toolName === "apply_patch" && typeof toolInput["command"] === "string") {
return {
...toolInput,
input: toolInput["command"],
patch: toolInput["command"],
};
}
return toolInput;
}
function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] {
if (typeof toolResponse === "string") {
return [{ type: "text", text: toolResponse }];
}
if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") {
return [{ type: "text", text: toolResponse["text"] }];
}
return [];
}
function isErrorResponse(toolResponse: unknown): boolean {
return isRecord(toolResponse) && toolResponse["is_error"] === true;
}
function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string {
return warnings
.map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`)
.join("\n\n");
}
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
return (
isRecord(value) &&
value["hook_event_name"] === "PostToolUse" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["tool_name"] === "string" &&
isRecord(value["tool_input"]) &&
typeof value["tool_use_id"] === "string"
);
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
processStdin.setEncoding("utf-8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", reject);
processStdin.once("end", () => {
resolve(data);
});
});
}
@@ -0,0 +1,361 @@
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image";
data: string;
mimeType: string;
};
export type CheckerToolName = "Write" | "Edit" | "MultiEdit";
export type CheckerEdit = {
old_string: string;
new_string: string;
};
export type CheckerToolInput = {
file_path: string;
content?: string;
old_string?: string;
new_string?: string;
edits?: CheckerEdit[];
};
export type CommentCheckRequest = {
sourceToolName: string;
toolName: CheckerToolName;
filePath: string;
toolInput: CheckerToolInput;
};
export type CommentCheckerHookInput = {
session_id: string;
tool_name: CheckerToolName;
transcript_path: string;
cwd: string;
hook_event_name: "PostToolUse";
tool_input: CheckerToolInput;
};
export type ToolResultContent = TextContent | ImageContent;
export type ToolResultLike = {
toolName: string;
input: Record<string, unknown>;
content?: ToolResultContent[];
isError?: boolean;
details?: unknown;
};
type ApplyPatchAccumulator = {
operation: "add" | "delete" | "update";
filePath: string;
movePath?: string;
oldLines: string[];
newLines: string[];
};
type ApplyPatchFileMetadata = {
filePath: string;
movePath?: string;
before: string;
after: string;
type?: string;
};
export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] {
if (event.isError) return [];
if (isToolFailureOutput(getContentText(event.content))) return [];
const toolName = event.toolName.toLowerCase();
if (toolName === "write") return extractWriteRequest(event);
if (toolName === "edit") return extractEditRequest(event);
if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event);
if (toolName === "apply_patch") return extractApplyPatchRequests(event);
return [];
}
export function toHookInput(
request: CommentCheckRequest,
context: {
sessionId: string;
cwd: string;
transcriptPath?: string;
},
): CommentCheckerHookInput {
return {
session_id: context.sessionId,
tool_name: request.toolName,
transcript_path: context.transcriptPath ?? "",
cwd: context.cwd,
hook_event_name: "PostToolUse",
tool_input: request.toolInput,
};
}
export function isToolFailureOutput(text: string): boolean {
const lower = text.trim().toLowerCase();
return (
lower.startsWith("error") ||
lower.includes("error:") ||
lower.includes("failed to") ||
lower.includes("could not")
);
}
function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const content = getString(event.input, ["content"]);
if (!filePath || content === undefined) return [];
return [
{
sourceToolName: event.toolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content,
},
},
];
}
function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const oldString = getString(event.input, ["oldString", "old_string"]);
const newString = getString(event.input, ["newString", "new_string"]);
if (!filePath || oldString === undefined || newString === undefined) return [];
const toolInput: CheckerToolInput = { file_path: filePath };
toolInput.old_string = oldString;
toolInput.new_string = newString;
return [
{
sourceToolName: event.toolName,
toolName: "Edit",
filePath,
toolInput,
},
];
}
function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const edits = getEdits(event.input["edits"]);
if (!filePath || edits.length === 0) return [];
return [
{
sourceToolName: event.toolName,
toolName: "MultiEdit",
filePath,
toolInput: {
file_path: filePath,
edits,
},
},
];
}
function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] {
const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName);
if (metadataRequests.length > 0) return metadataRequests;
const patch = getString(event.input, ["input", "patch", "command"]);
if (!patch) return [];
return parseApplyPatchRequests(patch, event.toolName);
}
function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] {
const metadataFiles = getApplyPatchMetadataFiles(details);
if (metadataFiles.length === 0) return [];
const requests: CommentCheckRequest[] = [];
for (const file of metadataFiles) {
if (file.type === "delete") continue;
const filePath = file.movePath ?? file.filePath;
if (file.before.length === 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content: file.after,
},
});
continue;
}
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: file.before,
new_string: file.after,
},
});
}
return requests;
}
function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] {
if (!isRecord(details)) return [];
const direct = readApplyPatchMetadataFiles(details["files"]);
if (direct.length > 0) return direct;
const resultDetails = details["result"];
const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : [];
if (result.length > 0) return result;
const metadataDetails = details["metadata"];
const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : [];
return metadata;
}
function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] {
if (!Array.isArray(value)) return [];
const files: ApplyPatchFileMetadata[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const filePath = getString(item, ["filePath", "file_path", "path"]);
const movePath = getString(item, ["movePath", "move_path"]);
const before = getString(item, ["before", "old", "oldString", "old_string"]);
const after = getString(item, ["after", "new", "newString", "new_string"]);
const type = getString(item, ["type", "operation"]);
if (!filePath || before === undefined || after === undefined) continue;
files.push({
filePath,
before,
after,
...(movePath === undefined ? {} : { movePath }),
...(type === undefined ? {} : { type }),
});
}
return files;
}
export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] {
const requests: CommentCheckRequest[] = [];
let current: ApplyPatchAccumulator | undefined;
const flush = (): void => {
if (!current) return;
if (current.operation === "add") {
const content = joinPatchLines(current.newLines);
if (content.length > 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath: current.filePath,
toolInput: {
file_path: current.filePath,
content,
},
});
}
}
if (current.operation === "update") {
const newString = joinPatchLines(current.newLines);
if (newString.length > 0) {
const filePath = current.movePath ?? current.filePath;
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: joinPatchLines(current.oldLines),
new_string: newString,
},
});
}
}
current = undefined;
};
for (const line of patch.split(/\r?\n/)) {
if (line === "*** Begin Patch" || line === "*** End Patch") continue;
if (line.startsWith("*** Add File: ")) {
flush();
current = makeAccumulator("add", line.slice("*** Add File: ".length).trim());
continue;
}
if (line.startsWith("*** Update File: ")) {
flush();
current = makeAccumulator("update", line.slice("*** Update File: ".length).trim());
continue;
}
if (line.startsWith("*** Delete File: ")) {
flush();
current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim());
continue;
}
if (line.startsWith("*** Move to: ")) {
if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim();
continue;
}
if (!current) continue;
if (line.startsWith("@@")) continue;
if (current.operation === "add") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
continue;
}
if (current.operation === "update") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
if (line.startsWith("-")) current.oldLines.push(line.slice(1));
}
}
flush();
return requests;
}
function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator {
return {
operation,
filePath,
oldLines: [],
newLines: [],
};
}
function getEdits(value: unknown): CheckerEdit[] {
if (!Array.isArray(value)) return [];
const edits: CheckerEdit[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const oldString = getString(item, ["oldString", "old_string"]);
const newString = getString(item, ["newString", "new_string"]);
if (oldString === undefined || newString === undefined) continue;
edits.push({
old_string: oldString,
new_string: newString,
});
}
return edits;
}
function getContentText(content: ToolResultContent[] | undefined): string {
if (!content) return "";
return content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
function getString(input: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = input[key];
if (typeof value === "string") return value;
}
return undefined;
}
function joinPatchLines(lines: string[]): string {
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,195 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import type { CommentCheckerHookInput } from "./core.js";
export type ProcessResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024;
export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise<ProcessResult>;
export type RunCommentCheckerOptions = {
binaryPath?: string;
customPrompt?: string;
resolveBinary?: () => string | undefined;
executor?: ProcessExecutor;
};
export type CommentCheckerRunResult = {
status: "pass" | "warning" | "error" | "missing";
message: string;
binaryPath?: string;
exitCode?: number | null;
stdout?: string;
stderr?: string;
};
export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise<CommentCheckerRunResult>;
export async function runCommentChecker(
input: CommentCheckerHookInput,
options: RunCommentCheckerOptions = {},
): Promise<CommentCheckerRunResult> {
const binaryPath =
options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary());
if (!binaryPath) {
return {
status: "missing",
message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.",
};
}
const args = ["check"];
if (options.customPrompt) {
args.push("--prompt", options.customPrompt);
}
const executor = options.executor ?? spawnProcess;
const result = await executor(binaryPath, args, JSON.stringify(input));
const message = result.stderr || result.stdout;
if (result.exitCode === 0) {
return {
status: "pass",
message: "",
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
if (result.exitCode === 2) {
return {
status: "warning",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
return {
status: "error",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
export function resolveCommentCheckerBinary(): string | undefined {
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker";
const fromPackageApi = resolvePackageApiBinary();
if (fromPackageApi) return fromPackageApi;
const fromPackage = resolvePackageBinary(binaryName);
if (fromPackage) return fromPackage;
return undefined;
}
function resolvePackageApiBinary(): string | undefined {
try {
const require = createRequire(import.meta.url);
const packageExports: unknown = require("@code-yeongyu/comment-checker");
if (!isCommentCheckerPackage(packageExports)) return undefined;
const binaryPath = packageExports.getBinaryPath();
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function resolvePackageBinary(binaryName: string): string | undefined {
try {
const require = createRequire(import.meta.url);
const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json");
const binaryPath = join(dirname(packagePath), "bin", binaryName);
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } {
return isRecord(value) && typeof value["getBinaryPath"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
interface OutputAccumulator {
text: string;
bytes: number;
truncated: boolean;
}
function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void {
if (output.truncated) return;
const remainingBytes = maxOutputBytes - output.bytes;
const chunkBytes = Buffer.byteLength(chunk, "utf8");
if (chunkBytes <= remainingBytes) {
output.text += chunk;
output.bytes += chunkBytes;
return;
}
if (remainingBytes > 0) {
output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8");
output.bytes += remainingBytes;
}
output.truncated = true;
}
function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string {
if (!output.truncated) return output.text;
return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`;
}
export function spawnProcess(
command: string,
args: string[],
stdin: string,
maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES,
): Promise<ProcessResult> {
return new Promise((resolve) => {
const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
const proc = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
});
const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false };
const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false };
proc.stdout.setEncoding("utf-8");
proc.stderr.setEncoding("utf-8");
proc.stdout.on("data", (chunk: string) => {
appendOutput(stdout, chunk, outputByteLimit);
});
proc.stderr.on("data", (chunk: string) => {
appendOutput(stderr, chunk, outputByteLimit);
});
proc.once("error", (error) => {
appendOutput(stderr, error.message, outputByteLimit);
resolve({
exitCode: null,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.once("close", (exitCode) => {
resolve({
exitCode,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.stdin.end(stdin);
});
}
@@ -0,0 +1,317 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
type CodexPostToolUseInput,
extractCodexCommentCheckRequests,
runCommentCheckerPostToolUse,
} from "../src/codex-hook.ts";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
function runHookCli(input: string): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", "post-tool-use"], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
function postToolUseInput(overrides: Partial<CodexPostToolUseInput> = {}): CodexPostToolUseInput {
return {
session_id: "thread-1",
turn_id: "turn-1",
transcript_path: null,
cwd: "/repo",
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "never",
tool_name: "apply_patch",
tool_input: {
command: [
"*** Begin Patch",
"*** Update File: src/example.ts",
"@@",
"-const value = 1;",
"+// explains value",
"+const value = 2;",
"*** End Patch",
].join("\n"),
},
tool_response: "Success. Updated files.",
tool_use_id: "call-1",
...overrides,
};
}
describe("extractCodexCommentCheckRequests", () => {
it("#given codex apply_patch command #when extracting #then returns edit request for changed file", () => {
const requests = extractCodexCommentCheckRequests(postToolUseInput());
expect(requests).toEqual([
{
sourceToolName: "apply_patch",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given unsupported post tool event #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "read",
tool_input: { file_path: "src/example.ts", content: "// hi\nconst value = 1;\n" },
}),
);
expect(requests).toEqual([]);
});
it("#given codex write payload #when extracting #then returns write request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "write",
toolName: "Write",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
},
]);
});
it("#given codex edit payload #when extracting #then returns edit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
newString: "// explains value\nconst value = 2;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "edit",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given one-sided codex edit payload #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
},
}),
);
expect(requests).toEqual([]);
});
it("#given codex multi_edit payload #when extracting #then returns multiedit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "multi_edit",
tool_input: {
filePath: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ oldString: "const b = 1;\n", newString: "// explains b\nconst b = 2;\n" },
],
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "multi_edit",
toolName: "MultiEdit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ old_string: "const b = 1;\n", new_string: "// explains b\nconst b = 2;\n" },
],
},
},
]);
});
});
describe("runCommentCheckerPostToolUse", () => {
it("#given checker warning #when hook runs #then returns blocking feedback JSON", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "warning",
message: "comment warning: explain less",
}),
});
expect(JSON.parse(output)).toEqual({
decision: "block",
reason: "comment-checker found issues in src/example.ts:\ncomment warning: explain less",
});
});
it("#given missing checker binary #when hook runs #then emits no hook output", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "missing",
message: "not installed",
}),
});
expect(output).toBe("");
});
it("#given transcript path #when hook runs #then forwards it to checker input", async () => {
let transcriptPath = "";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: "/tmp/codex-comment-checker-transcript.jsonl",
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("/tmp/codex-comment-checker-transcript.jsonl");
});
it("#given null transcript path #when hook runs #then forwards empty string fallback", async () => {
let transcriptPath = "unset";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: null,
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("");
});
});
describe("runCodexHookCli", () => {
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = '"break;"\n';
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-string transcript path #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ ...postToolUseInput(), transcript_path: 42 })}\n`;
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
@@ -0,0 +1,15 @@
{
"session_id": "00000000-0000-0000-0000-000000000000",
"turn_id": "00000000-0000-0000-0000-000000000001",
"transcript_path": "/tmp/codex-comment-checker-transcript.jsonl",
"cwd": ".",
"hook_event_name": "PostToolUse",
"model": "gpt-5.5",
"permission_mode": "default",
"tool_name": "apply_patch",
"tool_input": {
"command": "*** Begin Patch\n*** Add File: src/example.ts\n+export const meaning = 42;\n*** End Patch\n"
},
"tool_response": "Success. Updated files.",
"tool_use_id": "toolu_000000000000000000000000"
}
@@ -0,0 +1,109 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies?: Record<string, unknown>;
readonly optionalDependencies: Record<string, string>;
};
type PluginJson = {
readonly hooks: string;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readPluginJson(path: string): PluginJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).not.toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.optionalDependencies).toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.bin["codex-comment-checker"]).toBe("./dist/cli.js");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
isStringRecord(value["optionalDependencies"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isPluginJson(value: unknown): value is PluginJson {
return isRecord(value) && typeof value["hooks"] === "string";
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,66 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
MAX_PROCESS_OUTPUT_BYTES,
resolveCommentCheckerBinary,
runCommentChecker,
spawnProcess,
} from "../src/runner.js";
describe("spawnProcess", () => {
it("#given noisy checker process #when output exceeds cap #then stderr is bounded", async () => {
// given
const maxOutputBytes = 16;
// when
const result = await spawnProcess(
process.execPath,
["-e", "process.stderr.write('x'.repeat(40)); process.exit(2);"],
"",
maxOutputBytes,
);
// then
expect(MAX_PROCESS_OUTPUT_BYTES).toBeGreaterThan(maxOutputBytes);
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe(`${"x".repeat(maxOutputBytes)}\n[stderr truncated after 16 bytes]`);
});
});
describe("resolveCommentCheckerBinary", () => {
it("#given installed checker package #when resolving binary #then returns existing checker binary", () => {
// given / when
const binaryPath = resolveCommentCheckerBinary();
// then
expect(binaryPath).toBeDefined();
expect(binaryPath ?? "").toContain("comment-checker");
expect(existsSync(binaryPath ?? "")).toBe(true);
});
});
describe("runCommentChecker", () => {
it("#given missing checker binary #when runner starts #then returns missing result", async () => {
// given / when
const result = await runCommentChecker(
{
session_id: "session-1",
tool_name: "Write",
transcript_path: "",
cwd: "/repo",
hook_event_name: "PostToolUse",
tool_input: {
file_path: "src/example.ts",
content: "const value = 1;\n",
},
},
{
resolveBinary: () => undefined,
},
);
// then
expect(result.status).toBe("missing");
});
});
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"useDefineForClassFields": false,
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
pool: "threads",
},
});
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1 @@
* @code-yeongyu
@@ -0,0 +1,26 @@
name: Bug report
description: Report a reproducible codex-lsp bug.
title: "[bug]: "
labels: ["bug"]
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Exact steps, config, command output, and affected language server.
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: 0.1.0
validations:
required: true
@@ -0,0 +1,19 @@
name: Feature request
description: Propose a focused codex-lsp improvement.
title: "[feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow should improve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-lsp do?
validations:
required: true
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
@@ -0,0 +1,11 @@
## Summary
-
## Validation
-
## Notes
-
@@ -0,0 +1,56 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Bootstrap lsp-tools-mcp submodule
shell: bash
run: |
cd packages/lsp-tools-mcp
npm ci
npm run build
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,60 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Bootstrap lsp-tools-mcp submodule
shell: bash
run: |
cd packages/lsp-tools-mcp
npm ci
npm run build
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,6 @@
node_modules/
*.log
.env
.DS_Store
coverage/
.vitest/
@@ -0,0 +1,4 @@
[submodule "packages/lsp-tools-mcp"]
path = packages/lsp-tools-mcp
url = https://github.com/code-yeongyu/lsp-tools-mcp.git
branch = main
@@ -0,0 +1,9 @@
{
"mcpServers": {
"lsp": {
"command": "node",
"args": ["./packages/lsp-tools-mcp/dist/cli.js", "mcp"],
"cwd": "."
}
}
}
@@ -0,0 +1,25 @@
# Repository Conventions
Conventions for humans and agents working on this repository.
## Style
- TypeScript strict mode. No `any`, `@ts-ignore`, `@ts-expect-error`, or enums.
- ESM modules with `.js` suffix in import paths.
- Tabs for indentation. Double quotes for strings.
- Runtime is Node only.
- Tests use vitest and should exercise Codex hook/MCP behavior before implementation changes.
## Commands
- `npm install` installs dependencies.
- `npm test` runs the test suite once.
- `npm run typecheck` runs strict TypeScript checking.
- `npm run check` runs typecheck, Biome, and build.
## LSP Constraints
- LSP server processes are owned by `LspManager`.
- Tool execution acquires clients through `withLspClient(...)` unless it only reports static status.
- `lsp.rename` mutates files by applying workspace edits; keep it sequential at the MCP caller level.
- Do not add pi-coding-agent or omo source dependencies. This package is standalone.
@@ -0,0 +1,23 @@
# Changelog
## Unreleased
## 0.2.0
- Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp).
- codex-lsp now consumes that runtime as a git submodule at `packages/lsp-tools-mcp`.
- Kept the Codex-specific PostToolUse hook in this package and routed MCP serving through the upstream CLI.
- Extract LSP runtime to `lsp-tools-mcp` upstream and consume it via git submodule at `packages/lsp-tools-mcp`.
- Renamed the MCP server namespace to `lsp` and exposed shorter tool names such as `lsp.diagnostics`.
- Use portable Codex hook interpolation and add package smoke coverage for hook/MCP entrypoints.
- Spawn language servers without shell mode; Windows `.cmd` and `.bat` shims are routed through `cmd.exe` with explicit arguments.
- Cap directory diagnostics file traversal and run CI on Windows in addition to Ubuntu and macOS.
- Replace the external JSON-RPC runtime dependency with an internal LSP framing layer so clean Codex plugin installs run without `node_modules`.
## 0.1.0
- Ported the standalone LSP client, server resolution, diagnostics aggregation, and workspace edit runtime from `pi-lsp-client`.
- Added Codex `PostToolUse` diagnostics for edit-style tools.
- Added MCP tools for status, diagnostics, definitions, references, symbols, prepare rename, and rename.
- Added Codex plugin metadata, skill docs, CI, and release automation.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,3 @@
codex-lsp ports the standalone LSP runtime from pi-lsp-client into a Codex plugin.
The package includes adapted code originally developed for pi-lsp-client.
@@ -0,0 +1,152 @@
# codex-lsp
[![ci](https://github.com/code-yeongyu/codex-lsp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-lsp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Codex plugin that ports the standalone LSP runtime from [`pi-lsp-client`](https://github.com/code-yeongyu/pi-lsp-client). It gives Codex post-edit diagnostics plus explicit MCP tools for language-aware code work.
## Architecture
The LSP runtime moved to [`lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp) and is consumed here as a git submodule at `packages/lsp-tools-mcp/`.
- `codex-lsp` keeps Codex-specific integration (`hook post-tool-use`, plugin metadata, package wiring).
- `lsp-tools-mcp` owns MCP runtime, LSP manager, and tool implementations.
- `src/cli.ts` routes `mcp` to upstream runtime and keeps `hook post-tool-use` local.
## Behavior
| Case | Result |
|------|--------|
| `apply_patch` succeeds | parses `tool_input.command`, extracts added/updated/moved files, and checks each with LSP error diagnostics |
| `write` / `edit` / `multiedit` succeeds | checks `path`, `filePath`, or `file_path` aliases |
| diagnostics contain errors | returns Codex `PostToolUse` blocking feedback and injects the same diagnostics as additional context so Codex fixes the file |
| no diagnostics | emits no hook output |
| unsupported extension | emits no hook output |
| missing configured language server | surfaces the install/config message through hook or MCP output |
Deletes are ignored because they cannot introduce new diagnostics.
## MCP Tools
- `lsp.status`
- `lsp.diagnostics`
- `lsp.goto_definition`
- `lsp.find_references`
- `lsp.symbols`
- `lsp.prepare_rename`
- `lsp.rename`
`lsp.rename` applies the returned workspace edit to files. Use `lsp.prepare_rename` first when possible.
## Configuration
Project config:
```text
.codex/lsp-client.json
```
User config:
```text
~/.codex/lsp-client.json
```
Example:
```json
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".js", ".jsx"]
}
}
}
```
Built-in server definitions are used when no custom config overrides them. `lsp.status` shows which configured servers are installed or missing.
## Codex Plugin
The plugin ships:
- `.codex-plugin/plugin.json` for Codex plugin discovery.
- `.mcp.json` for the `lsp` MCP server.
- `hooks/hooks.json` for the `PostToolUse` diagnostics hook.
- `skills/lsp/SKILL.md` with MCP usage guidance.
The runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:./packages/lsp-tools-mcp`, so marketplace builds must include submodule contents.
The hook command is:
```bash
node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
```
The MCP command is:
```bash
node ./packages/lsp-tools-mcp/dist/cli.js mcp
```
## Local Development
```bash
git submodule update --init --recursive
npm run bootstrap # installs + builds the lsp-tools-mcp submodule
npm install
npm test
npm run typecheck
npm run check
npm pack --dry-run
```
The `bootstrap` script installs and builds the `lsp-tools-mcp` git submodule so
`@code-yeongyu/lsp-tools-mcp/dist/*.js` is available for the codex-lsp build.
Smoke-test the hook:
```bash
node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json
```
Smoke-test the MCP server:
```bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp
```
## Local Codex Installation
From the marketplace root containing this plugin:
```bash
codex plugin marketplace add /path/to/codex-plugins
node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins
```
If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache/<marketplace>/omo/0.1.0` and enables:
```toml
[plugins."omo@code-yeongyu-codex-plugins"]
enabled = true
```
## Branch Rules and Releases
- `main` is protected by `.github/branch-ruleset.json`.
- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows.
- Releases are GitHub Releases tagged as `v<semver>`.
- Publishing runs from the `publish` workflow after a GitHub Release is published.
## Privacy
This plugin runs locally. It starts configured language-server commands on your machine and does not call a network service by itself.
## License
[MIT](LICENSE).
## Related
- [pi-lsp-client](https://github.com/code-yeongyu/pi-lsp-client) - source extension this Codex plugin ports.
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "^(apply_patch|Write|Edit|MultiEdit|multi_edit|write|edit|multiedit)$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
"timeout": 60,
"statusMessage": "checking LSP diagnostics"
}
]
}
]
}
}
@@ -0,0 +1,64 @@
{
"name": "@code-yeongyu/codex-lsp",
"version": "0.2.0",
"description": "Codex plugin that exposes Language Server Protocol tools and post-edit diagnostics.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/codex-lsp",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/codex-lsp.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/codex-lsp/issues"
},
"keywords": [
"codex",
"codex-plugin",
"lsp",
"language-server-protocol",
"mcp",
"diagnostics"
],
"bin": {
"codex-lsp": "./dist/cli.js"
},
"files": [
"dist",
"hooks",
"skills",
".codex-plugin",
".mcp.json",
"LICENSE",
"NOTICE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"bootstrap": "node scripts/bootstrap-submodule.mjs",
"prebuild": "node scripts/bootstrap-submodule.mjs",
"build": "tsc -p tsconfig.build.json",
"pretest": "node scripts/bootstrap-submodule.mjs",
"test": "vitest --run",
"test:watch": "vitest",
"pretypecheck": "node scripts/bootstrap-submodule.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check src test",
"lint:fix": "biome check --write src test",
"precheck": "node scripts/bootstrap-submodule.mjs",
"check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json"
},
"dependencies": {
"@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1 @@
* @code-yeongyu
@@ -0,0 +1,26 @@
name: Bug report
description: Report a reproducible lsp-tools-mcp bug.
title: "[bug]: "
labels: ["bug"]
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Exact steps, config, command output, and affected MCP tool or language server.
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: 0.1.0
validations:
required: true
@@ -0,0 +1,19 @@
name: Feature request
description: Propose a focused lsp-tools-mcp improvement.
title: "[feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow should improve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should lsp-tools-mcp do?
validations:
required: true
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
@@ -0,0 +1,11 @@
## Summary
-
## Validation
-
## Notes
-
@@ -0,0 +1,47 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,51 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,6 @@
node_modules/
*.log
.env
.DS_Store
coverage/
.vitest/
@@ -0,0 +1,20 @@
# Changelog
All notable changes to this project are documented in this file.
## [0.1.0] - 2026-05-18
### Added
- Initial standalone extraction from `codex-lsp`:
- LSP runtime (`src/lsp/*`)
- MCP server (`src/mcp.ts`)
- Tool definitions (`src/tools.ts`)
- Standalone CLI (`src/cli.ts`, `mcp` subcommand only)
- Config path override support:
- `LSP_TOOLS_MCP_PROJECT_CONFIG`
- `LSP_TOOLS_MCP_USER_CONFIG`
- Full test suite import (excluding Codex-specific hook tests)
- CI workflow matrix (ubuntu/macos/windows x node 20/22)
- Release-triggered npm publish workflow
- Repository governance files (ruleset, CODEOWNERS, dependabot, issue templates, PR template)
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,3 @@
lsp-tools-mcp extracts the standalone LSP runtime from codex-lsp into a reusable package.
The package includes adapted code originally developed for pi-lsp-client.
@@ -0,0 +1,102 @@
# lsp-tools-mcp
[![ci](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Standalone Language Server Protocol tools exposed as a stdio MCP server.
## Used By
This repository is the upstream source of truth for two downstream plugins. Both consume it as a git submodule:
| Project | Path | Role |
|---------|------|------|
| **[codex-lsp](https://github.com/code-yeongyu/codex-lsp)** | `packages/lsp-tools-mcp/` | Codex plugin that ships these LSP MCP tools plus a Codex-specific PostToolUse diagnostics hook. |
| **[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)** (a.k.a. `oh-my-opencode`) | `vendor/lsp-tools-mcp/` | OpenCode plugin that registers this server as a built-in Tier-1 stdio MCP. Exposes `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, and `lsp_status` to all agents. |
If you fix or extend the LSP runtime here, both downstreams pick up the change by bumping the submodule pointer. Do not fork the runtime into a downstream; land changes here instead.
## Quick Start
```bash
npm install
npm run check
npm test
npm run build
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp
```
## MCP Tools
This server exposes the following tools:
- `lsp.status`
- `lsp.diagnostics`
- `lsp.goto_definition`
- `lsp.find_references`
- `lsp.symbols`
- `lsp.prepare_rename`
- `lsp.rename`
Tool aliases are also available for compatibility:
- `lsp_status`
- `lsp_diagnostics`
- `lsp_goto_definition`
- `lsp_find_references`
- `lsp_symbols`
- `lsp_prepare_rename`
- `lsp_rename`
When an MCP host registers this server under the name `lsp` (the default in both downstreams), the tools are exposed to agents as `lsp_status`, `lsp_diagnostics`, and so on, matching the alias names above.
## Configuration
Default config paths (matches codex-lsp's historical layout):
- Project: `.codex/lsp-client.json`
- User: `~/.codex/lsp-client.json`
Path overrides via environment variables:
- `LSP_TOOLS_MCP_PROJECT_CONFIG`
- `LSP_TOOLS_MCP_USER_CONFIG`
Examples (oh-my-openagent points the project config at `.opencode/lsp.json` via the env var):
```bash
LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json node dist/cli.js mcp
LSP_TOOLS_MCP_USER_CONFIG=.opencode/lsp.json node dist/cli.js mcp
```
Example config file:
```json
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".js", ".jsx"]
}
}
}
```
## Architecture
- `src/lsp/*` standalone LSP runtime (process management, JSON-RPC transport, configuration, diagnostics, workspace edits)
- `src/tools.ts` MCP tool definitions and handlers
- `src/mcp.ts` stdio MCP server entry and registration
- `src/cli.ts` standalone CLI entry (`mcp` subcommand only)
## Local Development
```bash
npm install
npm run check
npm test
npm pack --dry-run
```
## License
[MIT](LICENSE)
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,52 @@
{
"name": "@code-yeongyu/lsp-tools-mcp",
"version": "0.1.0",
"description": "Standalone Language Server Protocol tools exposed as a stdio MCP server.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/lsp-tools-mcp",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/lsp-tools-mcp.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/lsp-tools-mcp/issues"
},
"keywords": [
"mcp",
"lsp",
"language-server-protocol",
"model-context-protocol",
"typescript",
"nodejs"
],
"bin": {
"lsp-tools-mcp": "./dist/cli.js"
},
"files": [
"dist",
"LICENSE",
"NOTICE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"check": "tsc --noEmit && biome check . && npm run build"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { disposeDefaultLspManager } from "./lsp/manager.js";
import { runMcpStdioServer } from "./mcp.js";
async function main(): Promise<void> {
const [command = "mcp"] = argv.slice(2);
try {
if (command === "mcp") {
await runMcpStdioServer();
return;
}
stderr.write("Usage: lsp-tools-mcp [mcp]\n");
process.exitCode = 2;
} finally {
await disposeDefaultLspManager();
}
}
main().catch(async (error: unknown) => {
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
await disposeDefaultLspManager();
process.exitCode = 1;
});
@@ -0,0 +1,5 @@
export function reportBestEffortCleanupError(operation: string, error: unknown): void {
if (process.env["CODEX_LSP_DEBUG_CLEANUP"] !== "1") return;
const message = error instanceof Error ? error.message : String(error);
console.error(`[codex-lsp] ignored ${operation} failure during cleanup: ${message}`);
}
@@ -0,0 +1,146 @@
import { existsSync, statSync } from "node:fs";
import { dirname, extname, join, resolve } from "node:path";
import type { LspClient } from "./client.js";
import {
isLspDeadConnectionError,
LspInvalidPathError,
LspRequestTimeoutError,
LspServerInitializingError,
LspServerLookupError,
} from "./errors.js";
import { getLspManager, type LspManager } from "./manager.js";
import { findServerForExtension } from "./server-resolution.js";
import type { ServerLookupResult } from "./types.js";
const WORKSPACE_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"];
export function isDirectoryPath(filePath: string): boolean {
try {
return statSync(filePath).isDirectory();
} catch {
return false;
}
}
export function findWorkspaceRoot(filePath: string): string {
const abs = resolve(filePath);
let dir = abs;
if (!isDirectoryPath(dir)) {
dir = dirname(dir);
}
let prevDir = "";
while (dir !== prevDir) {
for (const marker of WORKSPACE_MARKERS) {
if (existsSync(join(dir, marker))) {
return dir;
}
}
prevDir = dir;
dir = dirname(dir);
}
return dirname(abs);
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result;
return [
`LSP server '${server.id}' is configured but NOT INSTALLED.`,
"",
`Command not found: ${server.command[0]}`,
"",
"To install:",
` ${installHint}`,
"",
`Supported extensions: ${server.extensions.join(", ")}`,
"",
"After installation, the server will be available automatically.",
].join("\n");
}
return [
`No LSP server configured for extension: ${result.extension}`,
"",
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${
result.availableServers.length > 10 ? "..." : ""
}`,
"",
"Configure a custom server in '.codex/lsp-client.json':",
" {",
' "lsp": {',
' "my-server": {',
' "command": ["my-lsp", "--stdio"],',
` "extensions": ["${result.extension}"]`,
" }",
" }",
" }",
].join("\n");
}
export interface WithLspClientOptions {
signal?: AbortSignal;
manager?: LspManager;
}
const READ_ONLY_RETRY_TOOLS = new Set([
"diagnostics",
"definition",
"references",
"documentSymbols",
"workspaceSymbols",
"prepareRename",
]);
export async function withLspClient<T>(
filePath: string,
fn: (client: LspClient) => Promise<T>,
toolName: string,
options: WithLspClientOptions = {},
): Promise<T> {
const absPath = resolve(filePath);
if (isDirectoryPath(absPath)) {
throw new LspInvalidPathError(
"Directory paths are not supported by this LSP tool. " +
"Use lsp.diagnostics with a directory path for directory diagnostics.",
);
}
const ext = extname(absPath);
const result = findServerForExtension(ext);
if (result.status !== "found") {
throw new LspServerLookupError(formatServerLookupError(result));
}
const server = result.server;
const root = findWorkspaceRoot(absPath);
const manager = options.manager ?? getLspManager();
const acquireAndCall = async (allowRetry: boolean): Promise<T> => {
const client = await manager.getClient(root, server, options.signal);
try {
return await fn(client);
} catch (err) {
if (allowRetry && READ_ONLY_RETRY_TOOLS.has(toolName) && isLspDeadConnectionError(err)) {
manager.invalidateClient(root, server.id, client);
return acquireAndCall(false);
}
if (err instanceof LspRequestTimeoutError) {
if (manager.isServerInitializing(root, server.id)) {
throw new LspServerInitializingError(err);
}
}
throw err;
} finally {
manager.releaseClient(root, server.id);
}
};
return acquireAndCall(true);
}
@@ -0,0 +1,170 @@
import { readFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { LspClientConnection } from "./connection.js";
import { getLanguageId } from "./language-mappings.js";
import type {
Diagnostic,
DocumentSymbol,
Location,
LocationLink,
PrepareRenameDefaultBehavior,
PrepareRenameResult,
Range,
SymbolInfo,
WorkspaceEdit,
} from "./types.js";
const POST_OPEN_DELAY_MS = 1000;
const POST_DIAGNOSTICS_WAIT_MS = 500;
export class LspClient extends LspClientConnection {
private readonly openedFiles = new Set<string>();
private readonly documentVersions = new Map<string, number>();
private readonly lastSyncedText = new Map<string, string>();
private readonly diagnosticPullErrors: Error[] = [];
getDiagnosticPullErrors(): readonly Error[] {
return this.diagnosticPullErrors;
}
async openFile(filePath: string): Promise<void> {
const absPath = resolve(filePath);
const uri = pathToFileURL(absPath).href;
const text = readFileSync(absPath, "utf-8");
if (!this.openedFiles.has(absPath)) {
const ext = extname(absPath);
const languageId = getLanguageId(ext);
const version = 1;
await this.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text,
},
});
this.openedFiles.add(absPath);
this.documentVersions.set(uri, version);
this.lastSyncedText.set(uri, text);
await new Promise((r) => setTimeout(r, POST_OPEN_DELAY_MS));
return;
}
const prevText = this.lastSyncedText.get(uri);
if (prevText === text) {
return;
}
const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1;
this.documentVersions.set(uri, nextVersion);
this.lastSyncedText.set(uri, text);
await this.sendNotification("textDocument/didChange", {
textDocument: { uri, version: nextVersion },
contentChanges: [{ text }],
});
await this.sendNotification("textDocument/didSave", {
textDocument: { uri },
text,
});
}
async definition(
filePath: string,
line: number,
character: number,
): Promise<Location | LocationLink | Array<Location | LocationLink> | null> {
const absPath = resolve(filePath);
await this.openFile(absPath);
return this.sendRequest<Location | LocationLink | Array<Location | LocationLink> | null>(
"textDocument/definition",
{
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
},
);
}
async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<Location[]> {
const absPath = resolve(filePath);
await this.openFile(absPath);
return this.sendRequest<Location[]>("textDocument/references", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
context: { includeDeclaration },
});
}
async documentSymbols(filePath: string): Promise<Array<DocumentSymbol | SymbolInfo>> {
const absPath = resolve(filePath);
await this.openFile(absPath);
return this.sendRequest<Array<DocumentSymbol | SymbolInfo>>("textDocument/documentSymbol", {
textDocument: { uri: pathToFileURL(absPath).href },
});
}
async workspaceSymbols(query: string): Promise<SymbolInfo[]> {
return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query });
}
private isUnsupportedDiagnosticPullError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = "code" in error && typeof error.code === "number" ? error.code : undefined;
if (code === -32601) return true;
return /unsupported|not supported|method not found|unknown request/i.test(error.message);
}
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
const absPath = resolve(filePath);
const uri = pathToFileURL(absPath).href;
await this.openFile(absPath);
await new Promise((r) => setTimeout(r, POST_DIAGNOSTICS_WAIT_MS));
try {
const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
textDocument: { uri },
});
if (result.items) {
return { items: result.items };
}
} catch (error) {
if (!this.isUnsupportedDiagnosticPullError(error)) {
this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error)));
}
}
return { items: this.getStoredDiagnostics(uri) };
}
async prepareRename(
filePath: string,
line: number,
character: number,
): Promise<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null> {
const absPath = resolve(filePath);
await this.openFile(absPath);
return this.sendRequest<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null>(
"textDocument/prepareRename",
{
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
},
);
}
async rename(filePath: string, line: number, character: number, newName: string): Promise<WorkspaceEdit | null> {
const absPath = resolve(filePath);
await this.openFile(absPath);
return this.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
textDocument: { uri: pathToFileURL(absPath).href },
position: { line: line - 1, character },
newName,
});
}
}
@@ -0,0 +1,188 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { BUILTIN_SERVERS } from "./server-definitions.js";
import type { ResolvedServer } from "./types.js";
interface LspEntry {
disabled?: boolean;
command?: string[];
extensions?: string[];
priority?: number;
env?: Record<string, string>;
initialization?: Record<string, unknown>;
}
interface ConfigJson {
lsp?: Record<string, unknown>;
}
type ConfigSource = "project" | "user";
export interface ServerWithSource extends ResolvedServer {
source: "project" | "user" | "builtin";
}
export function getConfigPaths(): { project: string; user: string } {
const cwd = process.cwd();
const projectOverride = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
const userOverride = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
return {
project: projectOverride
? isAbsolute(projectOverride)
? projectOverride
: join(cwd, projectOverride)
: join(cwd, ".codex", "lsp-client.json"),
user: userOverride
? isAbsolute(userOverride)
? userOverride
: join(homedir(), userOverride)
: join(homedir(), ".codex", "lsp-client.json"),
};
}
function loadJsonFile(path: string): ConfigJson | null {
if (!existsSync(path)) return null;
try {
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
return isConfigJson(parsed) ? parsed : null;
} catch {
return null;
}
}
export function loadAllConfigs(): Map<ConfigSource, ConfigJson> {
const paths = getConfigPaths();
const configs = new Map<ConfigSource, ConfigJson>();
const project = loadJsonFile(paths.project);
if (project) configs.set("project", project);
const user = loadJsonFile(paths.user);
if (user) configs.set("user", user);
return configs;
}
export function getMergedServers(): ServerWithSource[] {
const configs = loadAllConfigs();
const servers: ServerWithSource[] = [];
const disabled = new Set<string>();
const seen = new Set<string>();
const sources: ConfigSource[] = ["project", "user"];
for (const source of sources) {
const config = configs.get(source);
if (!config?.lsp) continue;
for (const [id, rawEntry] of Object.entries(config.lsp)) {
const entry = parseLspEntry(rawEntry);
if (!entry) continue;
if (entry.disabled) {
disabled.add(id);
continue;
}
if (seen.has(id)) continue;
if (!entry.command || !entry.extensions) continue;
const server: ServerWithSource = {
id,
command: entry.command,
extensions: entry.extensions,
priority: entry.priority ?? 0,
source,
};
if (entry.env !== undefined) {
server.env = entry.env;
}
if (entry.initialization !== undefined) {
server.initialization = entry.initialization;
}
servers.push(server);
seen.add(id);
}
}
for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
if (disabled.has(id) || seen.has(id)) continue;
servers.push({
id,
command: config.command,
extensions: config.extensions,
priority: -100,
source: "builtin",
});
}
return servers.sort((a, b) => {
if (a.source !== b.source) {
const order: Record<"project" | "user" | "builtin", number> = {
project: 0,
user: 1,
builtin: 2,
};
return order[a.source] - order[b.source];
}
return b.priority - a.priority;
});
}
function isConfigJson(value: unknown): value is ConfigJson {
if (!isRecord(value)) return false;
const lsp = value["lsp"];
return lsp === undefined || isRecord(lsp);
}
function parseLspEntry(value: unknown): LspEntry | null {
return isLspEntry(value) ? value : null;
}
function isLspEntry(value: unknown): value is LspEntry {
if (!isRecord(value)) return false;
const disabled = value["disabled"];
const command = value["command"];
const extensions = value["extensions"];
const priority = value["priority"];
const env = value["env"];
const initialization = value["initialization"];
return (
(disabled === undefined || typeof disabled === "boolean") &&
(command === undefined || isStringArray(command)) &&
(extensions === undefined || isStringArray(extensions)) &&
(priority === undefined || typeof priority === "number") &&
(env === undefined || isStringRecord(env)) &&
(initialization === undefined || isRecord(initialization))
);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function getDisabledServerIds(): Set<string> {
const configs = loadAllConfigs();
const disabled = new Set<string>();
for (const config of configs.values()) {
if (!config.lsp) continue;
for (const [id, rawEntry] of Object.entries(config.lsp)) {
const entry = parseLspEntry(rawEntry);
if (!entry) continue;
if (entry.disabled) disabled.add(id);
}
}
return disabled;
}
@@ -0,0 +1,69 @@
import { pathToFileURL } from "node:url";
import { LspClientTransport } from "./transport.js";
const INITIALIZE_SETTLE_MS = 300;
export class LspClientConnection extends LspClientTransport {
async initialize(): Promise<void> {
const rootUri = pathToFileURL(this.root).href;
await this.sendRequest("initialize", {
processId: process.pid,
rootUri,
rootPath: this.root,
workspaceFolders: [{ uri: rootUri, name: "workspace" }],
capabilities: {
textDocument: {
hover: { contentFormat: ["markdown", "plaintext"] },
definition: { linkSupport: true },
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
publishDiagnostics: {},
rename: {
prepareSupport: true,
prepareSupportDefaultBehavior: 1,
honorsChangeAnnotations: true,
},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
"source.fixAll",
],
},
},
isPreferredSupport: true,
disabledSupport: true,
dataSupport: true,
resolveSupport: {
properties: ["edit", "command"],
},
},
},
workspace: {
symbol: {},
workspaceFolders: true,
configuration: true,
applyEdit: true,
workspaceEdit: {
documentChanges: true,
},
},
},
initializationOptions: this.server.initialization,
});
await this.sendNotification("initialized");
await this.sendNotification("workspace/didChangeConfiguration", {
settings: { json: { validate: { enable: true } } },
});
// Some servers accept initialized before their diagnostics/indexing handlers are ready.
await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS));
}
}
@@ -0,0 +1,11 @@
export const DEFAULT_MAX_REFERENCES = 200;
export const DEFAULT_MAX_SYMBOLS = 200;
export const DEFAULT_MAX_DIAGNOSTICS = 200;
export const DEFAULT_MAX_DIRECTORY_FILES = 50;
export const REQUEST_TIMEOUT_MS = 15_000;
export const INIT_TIMEOUT_MS = 60_000;
export const IDLE_TIMEOUT_MS = 5 * 60_000;
export const REAPER_INTERVAL_MS = 60_000;
export const STOP_HARD_KILL_TIMEOUT_MS = 5_000;
export const STOP_SIGKILL_GRACE_MS = 1_000;
@@ -0,0 +1,152 @@
import { existsSync, lstatSync, readdirSync, type Stats } from "node:fs";
import { extname, join, resolve } from "node:path";
import { findWorkspaceRoot, formatServerLookupError } from "./client-wrapper.js";
import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants.js";
import { LspInvalidPathError, LspServerLookupError } from "./errors.js";
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./formatters.js";
import { getLspManager } from "./manager.js";
import { findServerForExtension } from "./server-resolution.js";
import type { Diagnostic, SeverityFilter } from "./types.js";
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
interface FileDiagnostic {
filePath: string;
diagnostic: Diagnostic;
}
export function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] {
const files: string[] = [];
function walk(currentDir: string): void {
if (files.length >= maxFiles) return;
let entries: string[] = [];
try {
entries = readdirSync(currentDir);
} catch {
return;
}
for (const entry of entries) {
if (files.length >= maxFiles) return;
const fullPath = join(currentDir, entry);
let stat: Stats | undefined;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (!stat || stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
if (!SKIP_DIRECTORIES.has(entry)) {
walk(fullPath);
}
} else if (stat.isFile() && extname(fullPath) === extension) {
files.push(fullPath);
}
}
}
walk(dir);
return files;
}
export async function aggregateDiagnosticsForDirectory(
directory: string,
extension: string,
severity?: SeverityFilter,
maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES,
): Promise<string> {
if (!extension.startsWith(".")) {
throw new LspInvalidPathError(
`Extension must start with a dot (e.g., ".ts", not "${extension}"). Use ".${extension}" instead.`,
);
}
const absDir = resolve(directory);
if (!existsSync(absDir)) {
throw new LspInvalidPathError(`Directory does not exist: ${absDir}`);
}
const serverResult = findServerForExtension(extension);
if (serverResult.status !== "found") {
throw new LspServerLookupError(formatServerLookupError(serverResult));
}
const server = serverResult.server;
const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1);
const wasCapped = allFiles.length > maxFiles;
const filesToProcess = allFiles.slice(0, maxFiles);
if (filesToProcess.length === 0) {
return [
`Directory: ${absDir}`,
`Extension: ${extension}`,
"Files scanned: 0",
`No files found with extension "${extension}".`,
].join("\n");
}
const root = findWorkspaceRoot(absDir);
const manager = getLspManager();
const allDiagnostics: FileDiagnostic[] = [];
const fileErrors: { file: string; error: string }[] = [];
const client = await manager.getClient(root, server);
try {
for (const file of filesToProcess) {
try {
const result = await client.diagnostics(file);
const filtered = filterDiagnosticsBySeverity(result.items, severity);
allDiagnostics.push(
...filtered.map((diagnostic) => ({
filePath: file,
diagnostic,
})),
);
} catch (e) {
fileErrors.push({
file,
error: e instanceof Error ? e.message : String(e),
});
}
}
} finally {
manager.releaseClient(root, server.id);
}
const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS);
const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS;
const lines: string[] = [
`Directory: ${absDir}`,
`Extension: ${extension}`,
`Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`,
`Files with errors: ${fileErrors.length}`,
`Total diagnostics: ${allDiagnostics.length}`,
];
if (fileErrors.length > 0) {
lines.push("", "File processing errors:");
for (const { file, error } of fileErrors) {
lines.push(` ${file}: ${error}`);
}
}
if (displayDiagnostics.length > 0) {
lines.push("");
for (const { filePath, diagnostic } of displayDiagnostics) {
lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`);
}
if (wasDiagCapped) {
lines.push("", `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`);
}
}
return lines.join("\n");
}
@@ -0,0 +1,63 @@
export class LspConnectionClosedError extends Error {
override readonly name = "LspConnectionClosedError";
constructor(
readonly serverId: string,
readonly root: string,
message?: string,
) {
super(message ?? `LSP connection closed for ${serverId} at ${root}`);
}
}
export class LspProcessExitedError extends Error {
override readonly name = "LspProcessExitedError";
constructor(
readonly serverId: string,
readonly root: string,
readonly exitCode: number | null,
readonly stderrTail?: string,
) {
const stderrSuffix = stderrTail ? `\nstderr tail: ${stderrTail}` : "";
super(`LSP server ${serverId} at ${root} exited with code ${exitCode ?? "null"}${stderrSuffix}`);
}
}
export class LspRequestTimeoutError extends Error {
override readonly name = "LspRequestTimeoutError";
constructor(
readonly method: string,
readonly stderrTail?: string,
) {
const stderrSuffix = stderrTail ? `\nrecent stderr: ${stderrTail}` : "";
super(`LSP request timeout (method: ${method})${stderrSuffix}`);
}
}
export class LspInvalidPathError extends Error {
override readonly name = "LspInvalidPathError";
}
export class LspServerLookupError extends Error {
override readonly name = "LspServerLookupError";
}
export class LspServerInitializingError extends Error {
override readonly name = "LspServerInitializingError";
constructor(readonly originalError: LspRequestTimeoutError) {
super(
`LSP server is still initializing. Please retry in a few seconds. Original error: ${originalError.message}`,
);
}
}
export class LspProcessSpawnError extends Error {
override readonly name = "LspProcessSpawnError";
}
export function isLspDeadConnectionError(err: unknown): err is LspConnectionClosedError | LspProcessExitedError {
return err instanceof LspConnectionClosedError || err instanceof LspProcessExitedError;
}
@@ -0,0 +1,141 @@
import { fileURLToPath } from "node:url";
import { SEVERITY_MAP, SYMBOL_KIND_MAP } from "./language-mappings.js";
import type {
Diagnostic,
DocumentSymbol,
Location,
LocationLink,
PrepareRenameDefaultBehavior,
PrepareRenameResult,
Range,
SeverityFilter,
SymbolInfo,
} from "./types.js";
import type { ApplyResult } from "./workspace-edit.js";
type FilteredSeverity = Exclude<SeverityFilter, "all">;
const DIAGNOSTIC_SEVERITY_FILTERS = {
error: 1,
warning: 2,
information: 3,
hint: 4,
} as const satisfies Readonly<Record<FilteredSeverity, number>>;
export function uriToPath(uri: string): string {
return fileURLToPath(uri);
}
export function formatLocation(loc: Location | LocationLink): string {
if ("targetUri" in loc) {
const uri = uriToPath(loc.targetUri);
const line = loc.targetRange.start.line + 1;
const char = loc.targetRange.start.character;
return `${uri}:${line}:${char}`;
}
const uri = uriToPath(loc.uri);
const line = loc.range.start.line + 1;
const char = loc.range.start.character;
return `${uri}:${line}:${char}`;
}
export function formatSymbolKind(kind: number): string {
return SYMBOL_KIND_MAP[kind] ?? `Unknown(${kind})`;
}
export function formatSeverity(severity: number | undefined): string {
if (!severity) return "unknown";
return SEVERITY_MAP[severity] ?? `unknown(${severity})`;
}
export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string {
const prefix = " ".repeat(indent);
const kind = formatSymbolKind(symbol.kind);
const line = symbol.range.start.line + 1;
let result = `${prefix}${symbol.name} (${kind}) - line ${line}`;
if (symbol.children && symbol.children.length > 0) {
for (const child of symbol.children) {
result += `\n${formatDocumentSymbol(child, indent + 1)}`;
}
}
return result;
}
export function formatSymbolInfo(symbol: SymbolInfo): string {
const kind = formatSymbolKind(symbol.kind);
const loc = formatLocation(symbol.location);
const container = symbol.containerName ? ` (in ${symbol.containerName})` : "";
return `${symbol.name} (${kind})${container} - ${loc}`;
}
export function formatDiagnostic(diag: Diagnostic): string {
const severity = formatSeverity(diag.severity);
const line = diag.range.start.line + 1;
const char = diag.range.start.character;
const source = diag.source ? `[${diag.source}]` : "";
const code = diag.code ? ` (${diag.code})` : "";
return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`;
}
export function filterDiagnosticsBySeverity(diagnostics: Diagnostic[], severityFilter?: SeverityFilter): Diagnostic[] {
if (!severityFilter || severityFilter === "all") {
return diagnostics;
}
const targetSeverity = DIAGNOSTIC_SEVERITY_FILTERS[severityFilter];
return diagnostics.filter((d) => d.severity === targetSeverity);
}
export function formatPrepareRenameResult(
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null,
): string {
if (!result) return "Cannot rename at this position";
if ("defaultBehavior" in result) {
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position";
}
if ("range" in result && result.range) {
const startLine = result.range.start.line + 1;
const startChar = result.range.start.character;
const endLine = result.range.end.line + 1;
const endChar = result.range.end.character;
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "";
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`;
}
if ("start" in result && "end" in result) {
const startLine = result.start.line + 1;
const startChar = result.start.character;
const endLine = result.end.line + 1;
const endChar = result.end.character;
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`;
}
return "Cannot rename at this position";
}
export function formatApplyResult(result: ApplyResult): string {
const lines: string[] = [];
if (result.success) {
lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`);
for (const file of result.filesModified) {
lines.push(` - ${file}`);
}
} else {
lines.push("Failed to apply some changes:");
for (const err of result.errors) {
lines.push(` Error: ${err}`);
}
if (result.filesModified.length > 0) {
lines.push(`Successfully modified: ${result.filesModified.join(", ")}`);
}
}
return lines.join("\n");
}
@@ -0,0 +1,65 @@
import { lstatSync, readdirSync } from "node:fs";
import { extname, join } from "node:path";
import { EXT_TO_LANG } from "./language-mappings.js";
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
const MAX_SCAN_ENTRIES = 500;
export function inferExtensionFromDirectory(directory: string): string | null {
const extensionCounts = new Map<string, number>();
let scanned = 0;
function walk(dir: string): void {
if (scanned >= MAX_SCAN_ENTRIES) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
if (scanned >= MAX_SCAN_ENTRIES) return;
const fullPath = join(dir, entry);
let stat: ReturnType<typeof lstatSync> | undefined;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (stat.isSymbolicLink()) continue;
scanned++;
if (stat.isDirectory()) {
if (!SKIP_DIRECTORIES.has(entry)) {
walk(fullPath);
}
} else if (stat.isFile()) {
const ext = extname(fullPath);
if (ext && ext in EXT_TO_LANG) {
extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1);
}
}
}
}
walk(directory);
if (extensionCounts.size === 0) return null;
let maxExt = "";
let maxCount = 0;
for (const [ext, count] of extensionCounts) {
if (count > maxCount) {
maxCount = count;
maxExt = ext;
}
}
return maxExt || null;
}
@@ -0,0 +1,296 @@
type JsonRpcId = number | string | null;
interface PendingRequest {
resolve(result: unknown): void;
reject(error: Error): void;
}
type NotificationHandler = (params: unknown) => void;
type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
const HEADER_SEPARATOR = "\r\n\r\n";
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INTERNAL_ERROR = -32603;
export class JsonRpcConnection {
private readonly pendingRequests = new Map<string, PendingRequest>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private readonly closeHandlers: Array<() => void> = [];
private readonly errorHandlers: Array<(error: Error) => void> = [];
private inputBuffer = Buffer.alloc(0);
private nextRequestId = 1;
private listening = false;
private disposed = false;
constructor(
private readonly reader: NodeJS.ReadableStream,
private readonly writer: NodeJS.WritableStream,
) {}
listen(): void {
if (this.listening) return;
this.listening = true;
this.reader.on("data", this.handleData);
this.reader.on("close", this.handleClose);
this.reader.on("end", this.handleClose);
this.reader.on("error", this.handleStreamError);
this.writer.on("error", this.handleStreamError);
}
onNotification(method: string, handler: NotificationHandler): void {
this.notificationHandlers.set(method, handler);
}
onRequest(method: string, handler: RequestHandler): void {
this.requestHandlers.set(method, handler);
}
onClose(handler: () => void): void {
this.closeHandlers.push(handler);
}
onError(handler: (error: Error) => void): void {
this.errorHandlers.push(handler);
}
async sendRequest<T>(method: string, params?: unknown): Promise<T> {
if (this.disposed) throw new Error("JSON-RPC connection is disposed");
const id = this.nextRequestId;
this.nextRequestId += 1;
const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params };
const responsePromise = new Promise<T>((resolve, reject) => {
this.pendingRequests.set(String(id), {
resolve(result) {
resolve(result as T);
},
reject,
});
});
try {
await this.writeMessage(message);
} catch (error) {
this.pendingRequests.delete(String(id));
throw error;
}
return responsePromise;
}
async sendNotification(method: string, params?: unknown): Promise<void> {
if (this.disposed) return;
const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params };
await this.writeMessage(message);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.reader.off("data", this.handleData);
this.reader.off("close", this.handleClose);
this.reader.off("end", this.handleClose);
this.reader.off("error", this.handleStreamError);
this.writer.off("error", this.handleStreamError);
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error("JSON-RPC connection disposed"));
}
this.pendingRequests.clear();
this.notificationHandlers.clear();
this.requestHandlers.clear();
}
private readonly handleData = (chunk: Buffer | string): void => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
this.inputBuffer = Buffer.concat([this.inputBuffer, chunkBuffer]);
this.drainInputBuffer();
};
private readonly handleClose = (): void => {
for (const handler of this.closeHandlers) {
handler();
}
};
private readonly handleStreamError = (error: Error): void => {
this.emitError(error);
};
private drainInputBuffer(): void {
while (true) {
const headerEnd = this.inputBuffer.indexOf(HEADER_SEPARATOR);
if (headerEnd === -1) return;
const headers = this.inputBuffer.subarray(0, headerEnd).toString("ascii");
const contentLength = parseContentLength(headers);
if (contentLength === null) {
this.inputBuffer = Buffer.alloc(0);
this.emitError(new Error("JSON-RPC message is missing Content-Length header"));
return;
}
const bodyStart = headerEnd + Buffer.byteLength(HEADER_SEPARATOR);
const bodyEnd = bodyStart + contentLength;
if (this.inputBuffer.length < bodyEnd) return;
const body = this.inputBuffer.subarray(bodyStart, bodyEnd).toString("utf8");
this.inputBuffer = this.inputBuffer.subarray(bodyEnd);
this.dispatchBody(body);
}
}
private dispatchBody(body: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch (error) {
void this.writeError(null, PARSE_ERROR, error instanceof Error ? error.message : "Parse error").catch(
(writeError) => this.emitError(toError(writeError)),
);
return;
}
if (!isJsonRpcObject(parsed)) {
void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC message").catch((error) =>
this.emitError(toError(error)),
);
return;
}
if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
this.handleResponse(parsed);
return;
}
if (typeof parsed["method"] !== "string") {
const id = getMessageId(parsed) ?? null;
void this.writeError(id, INVALID_REQUEST, "Invalid JSON-RPC method").catch((error) =>
this.emitError(toError(error)),
);
return;
}
if ("id" in parsed) {
this.handleRequest(parsed);
return;
}
this.handleNotification(parsed["method"], parsed["params"]);
}
private handleResponse(message: Record<string, unknown>): void {
const id = getMessageId(message);
if (id === undefined) return;
const pending = this.pendingRequests.get(String(id));
if (!pending) return;
this.pendingRequests.delete(String(id));
if ("error" in message) {
pending.reject(jsonRpcErrorToError(message["error"]));
return;
}
pending.resolve(message["result"]);
}
private handleNotification(method: string, params: unknown): void {
const handler = this.notificationHandlers.get(method);
if (!handler) return;
try {
handler(params);
} catch (error) {
this.emitError(toError(error));
}
}
private handleRequest(message: Record<string, unknown>): void {
const id = getMessageId(message);
if (id === undefined) {
void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC id").catch((error) =>
this.emitError(toError(error)),
);
return;
}
const method = typeof message["method"] === "string" ? message["method"] : "";
const handler = this.requestHandlers.get(method);
if (!handler) {
void this.writeError(id, METHOD_NOT_FOUND, `Method not found: ${method}`).catch((error) =>
this.emitError(toError(error)),
);
return;
}
Promise.resolve()
.then(() => handler(message["params"]))
.then(
(result) => this.writeMessage({ jsonrpc: "2.0", id, result }),
(error) => this.writeError(id, INTERNAL_ERROR, toError(error).message),
)
.catch((error) => this.emitError(toError(error)));
}
private async writeError(id: JsonRpcId, code: number, message: string): Promise<void> {
await this.writeMessage({ jsonrpc: "2.0", id, error: { code, message } });
}
private writeMessage(message: Record<string, unknown>): Promise<void> {
const body = JSON.stringify(message);
const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
return new Promise((resolve, reject) => {
this.writer.write(payload, (error?: Error | null) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
private emitError(error: Error): void {
for (const handler of this.errorHandlers) {
handler(error);
}
}
}
function parseContentLength(headers: string): number | null {
for (const line of headers.split("\r\n")) {
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) continue;
const name = line.slice(0, separatorIndex).trim().toLowerCase();
if (name !== "content-length") continue;
const value = Number.parseInt(line.slice(separatorIndex + 1).trim(), 10);
return Number.isFinite(value) && value >= 0 ? value : null;
}
return null;
}
function isJsonRpcObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getMessageId(message: Record<string, unknown>): JsonRpcId | undefined {
const id = message["id"];
if (typeof id === "number" || typeof id === "string" || id === null) return id;
return undefined;
}
function jsonRpcErrorToError(value: unknown): Error {
if (!isJsonRpcObject(value)) return new Error("JSON-RPC request failed");
const message = typeof value["message"] === "string" ? value["message"] : "JSON-RPC request failed";
const error = new Error(message);
if (typeof value["code"] === "number") {
error.name = `JsonRpcError(${value["code"]})`;
}
return error;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -0,0 +1,172 @@
export const SYMBOL_KIND_MAP: Record<number, string> = {
1: "File",
2: "Module",
3: "Namespace",
4: "Package",
5: "Class",
6: "Method",
7: "Property",
8: "Field",
9: "Constructor",
10: "Enum",
11: "Interface",
12: "Function",
13: "Variable",
14: "Constant",
15: "String",
16: "Number",
17: "Boolean",
18: "Array",
19: "Object",
20: "Key",
21: "Null",
22: "EnumMember",
23: "Struct",
24: "Event",
25: "Operator",
26: "TypeParameter",
};
export const SEVERITY_MAP: Record<number, string> = {
1: "error",
2: "warning",
3: "information",
4: "hint",
};
export const EXT_TO_LANG: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".edn": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".gleam": "gleam",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".jsonc": "jsonc",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl",
".pm6": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".pyi": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".svelte": "svelte",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".vue": "vue",
".zig": "zig",
".zon": "zig",
".astro": "astro",
".ml": "ocaml",
".mli": "ocaml",
".tf": "terraform",
".tfvars": "terraform-vars",
".hcl": "hcl",
".nix": "nix",
".typ": "typst",
".typc": "typst",
".ets": "typescript",
".lhs": "haskell",
".kt": "kotlin",
".kts": "kotlin",
".prisma": "prisma",
".h": "c",
".hpp": "cpp",
".hh": "cpp",
".hxx": "cpp",
".h++": "cpp",
".objc": "objective-c",
".objcpp": "objective-cpp",
".fish": "fish",
".graphql": "graphql",
".gql": "graphql",
};
export function getLanguageId(ext: string): string {
return EXT_TO_LANG[ext] ?? "plaintext";
}
@@ -0,0 +1,369 @@
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
import { LspClient } from "./client.js";
import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js";
import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
import type { ResolvedServer } from "./types.js";
interface ManagedClient {
client: LspClient;
refCount: number;
pendingWaiters: number;
lastUsedAt: number;
initPromise: Promise<void> | null;
isInitializing: boolean;
initializingSince: number | null;
}
export interface ClientSnapshot {
root: string;
serverId: string;
refCount: number;
pendingWaiters: number;
lastUsedAt: number;
isInitializing: boolean;
alive: boolean;
command: string[];
}
export interface LspManagerOptions {
idleTimeoutMs?: number;
initTimeoutMs?: number;
reaperIntervalMs?: number;
clientFactory?: (root: string, server: ResolvedServer) => LspClient;
now?: () => number;
}
async function stopClientBestEffort(client: LspClient): Promise<void> {
try {
await client.stop();
} catch (error) {
reportBestEffortCleanupError("client stop", error);
}
}
function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
reject(new DOMException("Aborted", "AbortError"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(err) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
}
export class LspManager {
private readonly clients = new Map<string, ManagedClient>();
private reaperHandle: NodeJS.Timeout | null = null;
private signalDisposer: (() => void) | null = null;
private disposed = false;
private readonly idleTimeoutMs: number;
private readonly initTimeoutMs: number;
private readonly reaperIntervalMs: number;
private readonly clientFactory: (root: string, server: ResolvedServer) => LspClient;
private readonly now: () => number;
constructor(options: LspManagerOptions = {}) {
this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS;
this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS;
this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server));
this.now = options.now ?? (() => Date.now());
this.startReaper();
this.signalDisposer = installProcessSignalCleanup(() => this.stopAll());
}
private startReaper(): void {
if (this.reaperHandle) return;
this.reaperHandle = setInterval(() => {
this.reapStale();
}, this.reaperIntervalMs);
if (typeof this.reaperHandle.unref === "function") {
this.reaperHandle.unref();
}
}
private getKey(root: string, serverId: string): string {
return `${root}::${serverId}`;
}
private reapStale(): void {
const t = this.now();
for (const [key, managed] of this.clients) {
if (
managed.isInitializing &&
managed.initializingSince !== null &&
t - managed.initializingSince > this.initTimeoutMs
) {
void stopClientBestEffort(managed.client);
this.clients.delete(key);
continue;
}
if (
!managed.isInitializing &&
managed.refCount === 0 &&
managed.pendingWaiters === 0 &&
t - managed.lastUsedAt > this.idleTimeoutMs
) {
void stopClientBestEffort(managed.client);
this.clients.delete(key);
}
}
}
private async tryDeleteIfOrphaned(key: string, managed: ManagedClient): Promise<void> {
if (
managed.refCount === 0 &&
managed.pendingWaiters === 0 &&
!managed.isInitializing &&
this.clients.get(key) === managed
) {
this.clients.delete(key);
await stopClientBestEffort(managed.client);
}
}
async getClient(root: string, server: ResolvedServer, signal?: AbortSignal): Promise<LspClient> {
if (this.disposed) {
throw new Error("LspManager has been disposed");
}
signal?.throwIfAborted();
const key = this.getKey(root, server.id);
let managed = this.clients.get(key);
if (managed) {
const t = this.now();
if (
managed.isInitializing &&
managed.initializingSince !== null &&
t - managed.initializingSince > this.initTimeoutMs
) {
await stopClientBestEffort(managed.client);
this.clients.delete(key);
managed = undefined;
}
}
if (managed) {
if (managed.initPromise) {
managed.pendingWaiters++;
try {
await awaitWithSignal(managed.initPromise, signal);
} catch (err) {
managed.pendingWaiters--;
await this.tryDeleteIfOrphaned(key, managed);
throw err;
}
managed.pendingWaiters--;
}
if (signal?.aborted) {
await this.tryDeleteIfOrphaned(key, managed);
signal.throwIfAborted();
}
if (!managed.client.isAlive()) {
await stopClientBestEffort(managed.client);
this.clients.delete(key);
return this.getClient(root, server, signal);
}
managed.refCount++;
managed.lastUsedAt = this.now();
return managed.client;
}
const client = this.clientFactory(root, server);
const initStartedAt = this.now();
const initPromise = (async () => {
await client.start();
await client.initialize();
})();
const newManaged: ManagedClient = {
client,
refCount: 0,
pendingWaiters: 1,
lastUsedAt: initStartedAt,
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
};
this.clients.set(key, newManaged);
try {
await awaitWithSignal(initPromise, signal);
} catch (err) {
newManaged.pendingWaiters--;
if (this.clients.get(key) === newManaged) {
this.clients.delete(key);
}
await stopClientBestEffort(client);
throw err;
}
newManaged.pendingWaiters--;
newManaged.isInitializing = false;
newManaged.initializingSince = null;
newManaged.initPromise = null;
if (signal?.aborted) {
await this.tryDeleteIfOrphaned(key, newManaged);
signal.throwIfAborted();
}
newManaged.refCount++;
newManaged.lastUsedAt = this.now();
return client;
}
releaseClient(root: string, serverId: string): void {
const key = this.getKey(root, serverId);
const managed = this.clients.get(key);
if (managed && managed.refCount > 0) {
managed.refCount--;
managed.lastUsedAt = this.now();
}
}
invalidateClient(root: string, serverId: string, client?: LspClient): void {
const key = this.getKey(root, serverId);
const managed = this.clients.get(key);
if (!managed) return;
if (client && managed.client !== client) return;
this.clients.delete(key);
void stopClientBestEffort(managed.client);
}
warmupClient(root: string, server: ResolvedServer): void {
if (this.disposed) return;
const key = this.getKey(root, server.id);
if (this.clients.has(key)) return;
const client = this.clientFactory(root, server);
const initStartedAt = this.now();
const initPromise = (async () => {
await client.start();
await client.initialize();
})();
const managed: ManagedClient = {
client,
refCount: 0,
pendingWaiters: 0,
lastUsedAt: initStartedAt,
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
};
this.clients.set(key, managed);
initPromise.then(
() => {
managed.isInitializing = false;
managed.initializingSince = null;
managed.initPromise = null;
managed.lastUsedAt = this.now();
},
() => {
if (this.clients.get(key) === managed) {
this.clients.delete(key);
}
void stopClientBestEffort(client);
},
);
}
isServerInitializing(root: string, serverId: string): boolean {
const managed = this.clients.get(this.getKey(root, serverId));
return managed?.isInitializing ?? false;
}
getSnapshot(): ClientSnapshot[] {
const snapshots: ClientSnapshot[] = [];
for (const [key, managed] of this.clients) {
const [root, serverId] = key.split("::") as [string, string];
snapshots.push({
root,
serverId,
refCount: managed.refCount,
pendingWaiters: managed.pendingWaiters,
lastUsedAt: managed.lastUsedAt,
isInitializing: managed.isInitializing,
alive: managed.client.isAlive(),
command: managed.client.command(),
});
}
return snapshots;
}
hasClient(root: string, serverId: string): boolean {
return this.clients.has(this.getKey(root, serverId));
}
clientCount(): number {
return this.clients.size;
}
async stopAll(): Promise<void> {
this.disposed = true;
if (this.reaperHandle) {
clearInterval(this.reaperHandle);
this.reaperHandle = null;
}
if (this.signalDisposer) {
this.signalDisposer();
this.signalDisposer = null;
}
const stopPromises: Promise<void>[] = [];
for (const managed of this.clients.values()) {
stopPromises.push(stopClientBestEffort(managed.client));
}
this.clients.clear();
await Promise.allSettled(stopPromises);
}
}
let _defaultInstance: LspManager | null = null;
export function getLspManager(): LspManager {
if (!_defaultInstance) {
_defaultInstance = new LspManager();
}
return _defaultInstance;
}
export async function disposeDefaultLspManager(): Promise<void> {
if (_defaultInstance) {
const m = _defaultInstance;
_defaultInstance = null;
await m.stopAll();
}
}
@@ -0,0 +1,21 @@
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
export function installProcessSignalCleanup(cleanup: () => Promise<void>): () => void {
const signals: readonly NodeJS.Signals[] =
process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGBREAK"] : ["SIGINT", "SIGTERM"];
const handler = () => {
void cleanup().catch((error) => {
reportBestEffortCleanupError("signal cleanup", error);
});
};
for (const signal of signals) {
process.on(signal, handler);
}
return () => {
for (const signal of signals) {
process.removeListener(signal, handler);
}
};
}
@@ -0,0 +1,202 @@
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
import { LspInvalidPathError, LspProcessSpawnError } from "./errors.js";
export interface SpawnedProcess {
stdin: NodeJS.WritableStream;
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
pid: number | undefined;
exitCode: number | null;
exited: Promise<number>;
kill(signal?: NodeJS.Signals): void;
killed: boolean;
}
export interface SpawnOptions {
cwd: string;
env: Record<string, string | undefined>;
}
export interface PreparedSpawnCommand {
command: string;
args: string[];
shell: false;
}
function isMissingProcessError(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) return false;
return error.code === "ESRCH";
}
function reportKillError(context: string, error: unknown): void {
if (!isMissingProcessError(error)) {
reportBestEffortCleanupError(context, error);
}
}
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
return { valid: false, error: `Working directory does not exist: ${cwd}` };
}
const stats = statSync(cwd);
if (!stats.isDirectory()) {
return { valid: false, error: `Path is not a directory: ${cwd}` };
}
return { valid: true };
} catch (err) {
return {
valid: false,
error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})`,
};
}
}
function wrap(proc: ChildProcess): SpawnedProcess {
const exitedPromise = new Promise<number>((resolve) => {
proc.once("close", (code) => resolve(code ?? 0));
proc.once("error", () => resolve(1));
});
if (!proc.stdin || !proc.stdout || !proc.stderr) {
throw new LspProcessSpawnError("Spawned process is missing one of stdin/stdout/stderr pipes");
}
return {
stdin: proc.stdin,
stdout: proc.stdout,
stderr: proc.stderr,
get pid() {
return proc.pid ?? undefined;
},
get exitCode() {
return proc.exitCode;
},
get killed() {
return proc.killed;
},
exited: exitedPromise,
kill(signal?: NodeJS.Signals) {
killProcessTree(proc, signal ?? "SIGTERM");
},
};
}
function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
if (process.platform === "win32" && proc.pid) {
const result = spawnSync("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" });
if (!result.error && result.status === 0) return;
if (result.error) reportKillError("windows process tree kill", result.error);
}
if (process.platform !== "win32" && proc.pid) {
try {
process.kill(-proc.pid, signal);
return;
} catch (error) {
reportKillError("process group kill", error);
}
}
try {
proc.kill(signal);
} catch (error) {
reportKillError("process kill", error);
}
}
function isWindowsShellShim(command: string): boolean {
const lowerCommand = command.toLowerCase();
return lowerCommand.endsWith(".cmd") || lowerCommand.endsWith(".bat");
}
function splitPath(pathValue: string, platform: NodeJS.Platform): string[] {
const separator = platform === "win32" ? ";" : delimiter;
return pathValue.split(separator).filter(Boolean);
}
function getWindowsPathExtensions(env: Record<string, string | undefined>): string[] {
const rawExtensions = env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD";
const extensions = rawExtensions
.split(";")
.map((extension) => extension.trim())
.filter(Boolean)
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
return [...new Set(["", ...extensions, ".exe", ".cmd", ".bat"])];
}
function resolveWindowsCommand(command: string, env: Record<string, string | undefined>): string {
const hasPathSeparator = command.includes("/") || command.includes("\\");
const pathValue = env["PATH"] ?? env["Path"] ?? "";
const baseDirectories = hasPathSeparator ? [""] : splitPath(pathValue, "win32");
const extensions = getWindowsPathExtensions(env);
for (const baseDirectory of baseDirectories) {
for (const extension of extensions) {
const candidate = baseDirectory ? join(baseDirectory, `${command}${extension}`) : `${command}${extension}`;
if (existsSync(candidate)) return candidate;
}
}
return command;
}
export function createSpawnCommand(
command: string[],
platform: NodeJS.Platform = process.platform,
commandProcessor: string = process.env["ComSpec"] ?? "cmd.exe",
env: Record<string, string | undefined> = process.env,
): PreparedSpawnCommand {
const [cmd, ...args] = command;
if (!cmd) {
throw new LspProcessSpawnError("[lsp] empty command");
}
if (platform !== "win32") {
return { command: cmd, args, shell: false };
}
const resolvedCommand = resolveWindowsCommand(cmd, env);
if (!isWindowsShellShim(resolvedCommand)) {
return { command: resolvedCommand, args, shell: false };
}
return {
command: commandProcessor,
args: ["/d", "/s", "/c", resolvedCommand, ...args],
shell: false,
};
}
export function spawnProcess(command: string[], options: SpawnOptions): SpawnedProcess {
const cwdValidation = validateCwd(options.cwd);
if (!cwdValidation.valid) {
throw new LspInvalidPathError(`[lsp] ${cwdValidation.error}`);
}
const [cmd] = command;
if (!cmd) {
throw new LspProcessSpawnError("[lsp] empty command");
}
const preparedCommand = createSpawnCommand(
command,
process.platform,
process.env["ComSpec"] ?? "cmd.exe",
options.env,
);
const proc = spawn(preparedCommand.command, preparedCommand.args, {
cwd: options.cwd,
env: options.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: preparedCommand.shell,
detached: process.platform !== "win32",
});
return wrap(proc);
}
@@ -0,0 +1,163 @@
import type { LspServerConfig } from "./types.js";
export const LSP_INSTALL_HINTS: Record<string, string> = {
typescript: "npm install -g typescript-language-server typescript",
deno: "Install Deno from https://deno.land",
vue: "npm install -g @vue/language-server",
eslint: "npm install -g vscode-langservers-extracted",
oxlint: "npm install -g oxlint",
biome: "npm install -g @biomejs/biome",
gopls: "go install golang.org/x/tools/gopls@latest",
"ruby-lsp": "gem install ruby-lsp",
basedpyright: "pip install basedpyright",
pyright: "pip install pyright",
ty: "pip install ty",
ruff: "pip install ruff",
"elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
zls: "See https://github.com/zigtools/zls",
csharp: "dotnet tool install -g csharp-ls",
fsharp: "dotnet tool install -g fsautocomplete",
"sourcekit-lsp": "Included with Xcode or Swift toolchain",
rust:
"Install rust-analyzer and ensure it is in PATH. If using rustup: rustup component add rust-analyzer. " +
"If rust-analyzer exits while loading rust-src: rustup component remove rust-src && rustup component add rust-src.",
clangd: "See https://clangd.llvm.org/installation",
svelte: "npm install -g svelte-language-server",
astro: "npm install -g @astrojs/language-server",
"bash-ls": "npm install -g bash-language-server",
jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
"yaml-ls": "npm install -g yaml-language-server",
"lua-ls": "See https://github.com/LuaLS/lua-language-server",
php: "npm install -g intelephense",
dart: "Included with Dart SDK",
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
terraform: "See https://github.com/hashicorp/terraform-ls",
prisma: "npm install -g prisma",
"ocaml-lsp": "opam install ocaml-lsp-server",
texlab: "See https://github.com/latex-lsp/texlab",
dockerfile: "npm install -g dockerfile-language-server-nodejs",
gleam: "See https://gleam.run/getting-started/installing/",
"clojure-lsp": "See https://clojure-lsp.io/installation/",
nixd: "nix profile install nixpkgs#nixd",
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
"haskell-language-server": "ghcup install hls",
bash: "npm install -g bash-language-server",
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
};
export const BUILTIN_SERVERS: Record<string, Omit<LspServerConfig, "id">> = {
typescript: {
command: ["typescript-language-server", "--stdio"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
},
deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] },
vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] },
eslint: {
command: ["vscode-eslint-language-server", "--stdio"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
},
oxlint: {
command: ["oxlint", "--lsp"],
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
},
biome: {
command: ["biome", "lsp-proxy", "--stdio"],
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
".json",
".jsonc",
".vue",
".astro",
".svelte",
".css",
".graphql",
".gql",
".html",
],
},
gopls: { command: ["gopls"], extensions: [".go"] },
"ruby-lsp": {
command: ["rubocop", "--lsp"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
},
basedpyright: {
command: ["basedpyright-langserver", "--stdio"],
extensions: [".py", ".pyi"],
},
pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] },
ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] },
"elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] },
zls: { command: ["zls"], extensions: [".zig", ".zon"] },
csharp: { command: ["csharp-ls"], extensions: [".cs"] },
fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] },
"sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
clangd: {
command: ["clangd", "--background-index", "--clang-tidy"],
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
},
svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] },
astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] },
bash: {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
},
"bash-ls": {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
},
jdtls: { command: ["jdtls"], extensions: [".java"] },
"yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] },
"lua-ls": { command: ["lua-language-server"], extensions: [".lua"] },
php: { command: ["intelephense", "--stdio"], extensions: [".php"] },
dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] },
terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
"terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] },
"ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] },
texlab: { command: ["texlab"], extensions: [".tex", ".bib"] },
dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] },
gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] },
"clojure-lsp": {
command: ["clojure-lsp", "listen"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
},
nixd: { command: ["nixd"], extensions: [".nix"] },
tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] },
"haskell-language-server": {
command: ["haskell-language-server-wrapper", "--lsp"],
extensions: [".hs", ".lhs"],
},
"kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] },
};
export const AUTO_INSTALLABLE_SERVERS: Record<string, string[]> = {
typescript: ["npm", "install", "-g", "typescript-language-server", "typescript"],
vue: ["npm", "install", "-g", "@vue/language-server"],
eslint: ["npm", "install", "-g", "vscode-langservers-extracted"],
oxlint: ["npm", "install", "-g", "oxlint"],
biome: ["npm", "install", "-g", "@biomejs/biome"],
svelte: ["npm", "install", "-g", "svelte-language-server"],
astro: ["npm", "install", "-g", "@astrojs/language-server"],
"bash-ls": ["npm", "install", "-g", "bash-language-server"],
bash: ["npm", "install", "-g", "bash-language-server"],
"yaml-ls": ["npm", "install", "-g", "yaml-language-server"],
php: ["npm", "install", "-g", "intelephense"],
prisma: ["npm", "install", "-g", "prisma"],
dockerfile: ["npm", "install", "-g", "dockerfile-language-server-nodejs"],
gopls: ["go", "install", "golang.org/x/tools/gopls@latest"],
pyright: ["pip", "install", "pyright"],
basedpyright: ["pip", "install", "basedpyright"],
ruff: ["pip", "install", "ruff"],
ty: ["pip", "install", "ty"],
"ruby-lsp": ["gem", "install", "ruby-lsp"],
"ocaml-lsp": ["opam", "install", "ocaml-lsp-server"],
};
@@ -0,0 +1,57 @@
import { existsSync } from "node:fs";
import { delimiter, join } from "node:path";
export function getAdditionalPathBases(workingDirectory: string): string[] {
return [join(workingDirectory, "node_modules", ".bin")];
}
export function isServerInstalled(command: string[]): boolean {
if (command.length === 0) return false;
const [cmd] = command;
if (!cmd) return false;
if (cmd.includes("/") || cmd.includes("\\")) {
if (existsSync(cmd)) return true;
}
const isWindows = process.platform === "win32";
let exts = [""];
if (isWindows) {
const pathExt = process.env["PATHEXT"] ?? "";
if (pathExt) {
const systemExts = pathExt.split(";").filter(Boolean);
exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])];
} else {
exts = ["", ".exe", ".cmd", ".bat", ".ps1"];
}
}
let pathEnv = process.env["PATH"] ?? "";
if (isWindows && !pathEnv) {
pathEnv = process.env["Path"] ?? "";
}
const paths = pathEnv.split(delimiter);
for (const p of paths) {
for (const suffix of exts) {
if (existsSync(join(p, cmd + suffix))) {
return true;
}
}
}
for (const base of getAdditionalPathBases(process.cwd())) {
for (const suffix of exts) {
if (existsSync(join(base, cmd + suffix))) {
return true;
}
}
}
if (cmd === "node") return true;
return false;
}
@@ -0,0 +1,104 @@
import { getDisabledServerIds, getMergedServers } from "./config-loader.js";
import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions.js";
import { isServerInstalled } from "./server-installation.js";
import type { ServerLookupResult } from "./types.js";
export function findServerForExtension(ext: string): ServerLookupResult {
const servers = getMergedServers();
for (const server of servers) {
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
const resolvedServer = {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
};
if (server.env !== undefined) {
return {
status: "found",
server: {
...resolvedServer,
env: server.env,
...(server.initialization === undefined ? {} : { initialization: server.initialization }),
},
};
}
return {
status: "found",
server: {
...resolvedServer,
...(server.initialization === undefined ? {} : { initialization: server.initialization }),
},
};
}
}
for (const server of servers) {
if (server.extensions.includes(ext)) {
const installHint =
LSP_INSTALL_HINTS[server.id] ?? `Install '${server.command[0]}' and ensure it's in your PATH`;
return {
status: "not_installed",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
},
installHint,
};
}
}
const availableServers = [...new Set(servers.map((s) => s.id))];
return {
status: "not_configured",
extension: ext,
availableServers,
};
}
export interface ServerStatus {
id: string;
installed: boolean;
extensions: string[];
disabled: boolean;
source: string;
priority: number;
}
export function getAllServers(): ServerStatus[] {
const servers = getMergedServers();
const disabled = getDisabledServerIds();
const result: ServerStatus[] = [];
const seen = new Set<string>();
for (const server of servers) {
if (seen.has(server.id)) continue;
result.push({
id: server.id,
installed: isServerInstalled(server.command),
extensions: server.extensions,
disabled: false,
source: server.source,
priority: server.priority,
});
seen.add(server.id);
}
for (const id of disabled) {
if (seen.has(id)) continue;
const builtin = BUILTIN_SERVERS[id];
result.push({
id,
installed: builtin ? isServerInstalled(builtin.command) : false,
extensions: builtin?.extensions ?? [],
disabled: true,
source: "disabled",
priority: 0,
});
}
return result;
}
@@ -0,0 +1,285 @@
import { delimiter } from "node:path";
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
import { REQUEST_TIMEOUT_MS, STOP_HARD_KILL_TIMEOUT_MS, STOP_SIGKILL_GRACE_MS } from "./constants.js";
import { LspConnectionClosedError, LspProcessExitedError, LspRequestTimeoutError } from "./errors.js";
import { JsonRpcConnection } from "./json-rpc-connection.js";
import { type SpawnedProcess, spawnProcess } from "./process.js";
import { getAdditionalPathBases } from "./server-installation.js";
import type { Diagnostic, ResolvedServer } from "./types.js";
interface ConfigurationItem {
section?: string;
}
interface DiagnosticsParams {
uri: string;
diagnostics: Diagnostic[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseConfigurationItems(params: unknown): ConfigurationItem[] {
if (!isRecord(params) || !Array.isArray(params["items"])) return [];
const items: ConfigurationItem[] = [];
for (const item of params["items"]) {
if (!isRecord(item)) continue;
const section = item["section"];
items.push(section === undefined || typeof section !== "string" ? {} : { section });
}
return items;
}
function parseDiagnosticsParams(params: unknown): DiagnosticsParams | null {
if (!isRecord(params) || typeof params["uri"] !== "string") return null;
const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : [];
return { uri: params["uri"], diagnostics };
}
export class LspClientTransport {
protected proc: SpawnedProcess | null = null;
protected connection: JsonRpcConnection | null = null;
protected readonly stderrBuffer: string[] = [];
protected processExited = false;
protected readonly diagnosticsStore = new Map<string, Diagnostic[]>();
constructor(
protected readonly root: string,
protected readonly server: ResolvedServer,
) {}
pid(): number | undefined {
return this.proc?.pid;
}
command(): string[] {
return [...this.server.command];
}
async start(): Promise<void> {
const env: Record<string, string | undefined> = {
...process.env,
...this.server.env,
};
const pathValue = process.platform === "win32" ? (env["PATH"] ?? env["Path"] ?? "") : (env["PATH"] ?? "");
const spawnPath = [pathValue, ...getAdditionalPathBases(this.root)].filter(Boolean).join(delimiter);
if (process.platform === "win32" && env["Path"] !== undefined) {
env["Path"] = spawnPath;
}
env["PATH"] = spawnPath;
this.proc = spawnProcess(this.server.command, {
cwd: this.root,
env,
});
this.startStderrReading();
await new Promise<void>((resolve) => setTimeout(resolve, 100));
if (this.proc.exitCode !== null) {
const stderr = this.stderrBuffer.join("\n");
throw new LspProcessExitedError(this.server.id, this.root, this.proc.exitCode, stderr.slice(-2000));
}
this.connection = new JsonRpcConnection(this.proc.stdout, this.proc.stdin);
this.connection.onNotification("textDocument/publishDiagnostics", (params) => {
const diagnosticsParams = parseDiagnosticsParams(params);
if (diagnosticsParams?.uri) {
this.diagnosticsStore.set(diagnosticsParams.uri, diagnosticsParams.diagnostics);
}
});
this.connection.onRequest("workspace/configuration", (params) => {
const items = parseConfigurationItems(params);
return items.map((item) => {
if (item.section === "json") return { validate: { enable: true } };
return {};
});
});
this.connection.onRequest("client/registerCapability", () => null);
this.connection.onRequest("window/workDoneProgress/create", () => null);
this.connection.onClose(() => {
this.processExited = true;
});
this.connection.onError((error) => {
reportBestEffortCleanupError("connection error notification", error);
});
this.connection.listen();
}
protected startStderrReading(): void {
if (!this.proc) return;
this.proc.stderr.setEncoding("utf-8");
this.proc.stderr.on("data", (chunk: string) => {
this.stderrBuffer.push(chunk);
if (this.stderrBuffer.length > 100) {
this.stderrBuffer.shift();
}
});
}
private isConnectionClosedError(error: unknown): error is Error {
if (!(error instanceof Error)) {
return false;
}
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return (
code === "ERR_STREAM_DESTROYED" ||
/connection closed|connection is disposed|stream was destroyed/i.test(error.message)
);
}
protected sendRequest<T>(method: string): Promise<T>;
protected sendRequest<T>(method: string, params: unknown): Promise<T>;
protected async sendRequest<T>(method: string, ...args: [] | [unknown]): Promise<T> {
if (!this.connection) throw new Error("LSP client not started");
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
const stderrTail = this.stderrBuffer.slice(-10).join("\n");
throw new LspProcessExitedError(
this.server.id,
this.root,
this.proc?.exitCode ?? null,
stderrTail || undefined,
);
}
let timeoutHandle: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
const stderrTail = this.stderrBuffer.slice(-5).join("\n");
reject(new LspRequestTimeoutError(method, stderrTail || undefined));
}, REQUEST_TIMEOUT_MS);
});
try {
const requestPromise =
args.length === 0
? this.connection.sendRequest<T>(method)
: this.connection.sendRequest<T>(method, args[0]);
const result = await Promise.race([requestPromise, timeoutPromise]);
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
return result;
} catch (error) {
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
throw new LspProcessExitedError(
this.server.id,
this.root,
this.proc?.exitCode ?? null,
this.stderrBuffer.slice(-10).join("\n") || undefined,
);
}
if (this.isConnectionClosedError(error)) {
throw new LspConnectionClosedError(this.server.id, this.root, error.message);
}
throw error;
}
}
protected sendNotification(method: string): Promise<void>;
protected sendNotification(method: string, params: unknown): Promise<void>;
protected async sendNotification(method: string, ...args: [] | [unknown]): Promise<void> {
if (!this.connection) return;
if (this.processExited || (this.proc && this.proc.exitCode !== null)) return;
try {
if (args.length === 0) {
await this.connection.sendNotification(method);
} else {
await this.connection.sendNotification(method, args[0]);
}
} catch (error) {
if (this.isConnectionClosedError(error)) {
throw new LspConnectionClosedError(this.server.id, this.root, error.message);
}
throw error;
}
}
isAlive(): boolean {
return this.proc !== null && !this.processExited && this.proc.exitCode === null;
}
async stop(): Promise<void> {
if (this.connection) {
try {
await this.sendRequest<null>("shutdown");
} catch (error) {
reportBestEffortCleanupError("shutdown request", error);
}
try {
await this.sendNotification("exit");
} catch (error) {
reportBestEffortCleanupError("exit notification", error);
}
try {
this.connection.dispose();
} catch (error) {
reportBestEffortCleanupError("connection dispose", error);
}
this.connection = null;
}
const proc = this.proc;
if (proc) {
this.proc = null;
let exitedBeforeTimeout = false;
try {
proc.kill();
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, STOP_HARD_KILL_TIMEOUT_MS);
});
await Promise.race([
proc.exited
.then(() => {
exitedBeforeTimeout = true;
})
.finally(() => {
if (timeoutId) clearTimeout(timeoutId);
}),
timeoutPromise,
]);
if (!exitedBeforeTimeout) {
try {
proc.kill("SIGKILL");
await Promise.race([
proc.exited,
new Promise<void>((resolve) => setTimeout(resolve, STOP_SIGKILL_GRACE_MS)),
]);
} catch (error) {
reportBestEffortCleanupError("hard process kill", error);
}
}
} catch (error) {
reportBestEffortCleanupError("process stop", error);
}
}
this.processExited = true;
this.diagnosticsStore.clear();
}
getStoredDiagnostics(uri: string): Diagnostic[] {
return this.diagnosticsStore.get(uri) ?? [];
}
}
function isDiagnostic(value: unknown): value is Diagnostic {
return isRecord(value) && isRange(value["range"]) && typeof value["message"] === "string";
}
function isRange(value: unknown): value is Diagnostic["range"] {
return isRecord(value) && isPosition(value["start"]) && isPosition(value["end"]);
}
function isPosition(value: unknown): value is Diagnostic["range"]["start"] {
return isRecord(value) && typeof value["line"] === "number" && typeof value["character"] === "number";
}
@@ -0,0 +1,126 @@
export interface LspServerConfig {
id: string;
command: string[];
extensions: string[];
disabled?: boolean;
env?: Record<string, string>;
initialization?: Record<string, unknown>;
}
export interface ResolvedServer {
id: string;
command: string[];
extensions: string[];
priority: number;
env?: Record<string, string>;
initialization?: Record<string, unknown>;
}
export interface ServerLookupInfo {
id: string;
command: string[];
extensions: string[];
}
export type ServerLookupResult =
| { status: "found"; server: ResolvedServer }
| { status: "not_configured"; extension: string; availableServers: string[] }
| { status: "not_installed"; server: ServerLookupInfo; installHint: string };
export interface Position {
line: number;
character: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Location {
uri: string;
range: Range;
}
export interface LocationLink {
targetUri: string;
targetRange: Range;
targetSelectionRange: Range;
originSelectionRange?: Range;
}
export interface SymbolInfo {
name: string;
kind: number;
location: Location;
containerName?: string;
}
export interface DocumentSymbol {
name: string;
kind: number;
range: Range;
selectionRange: Range;
children?: DocumentSymbol[];
}
export interface Diagnostic {
range: Range;
severity?: number;
code?: string | number;
source?: string;
message: string;
}
export interface TextDocumentIdentifier {
uri: string;
}
export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
version: number | null;
}
export interface TextEdit {
range: Range;
newText: string;
}
export interface TextDocumentEdit {
textDocument: VersionedTextDocumentIdentifier;
edits: TextEdit[];
}
export interface CreateFile {
kind: "create";
uri: string;
options?: { overwrite?: boolean; ignoreIfExists?: boolean };
}
export interface RenameFile {
kind: "rename";
oldUri: string;
newUri: string;
options?: { overwrite?: boolean; ignoreIfExists?: boolean };
}
export interface DeleteFile {
kind: "delete";
uri: string;
options?: { recursive?: boolean; ignoreIfNotExists?: boolean };
}
export interface WorkspaceEdit {
changes?: { [uri: string]: TextEdit[] };
documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[];
}
export interface PrepareRenameResult {
range: Range;
placeholder?: string;
}
export interface PrepareRenameDefaultBehavior {
defaultBehavior: boolean;
}
export type SeverityFilter = "error" | "warning" | "information" | "hint" | "all";
@@ -0,0 +1,40 @@
import { LspProcessExitedError } from "./errors.js";
const RUST_SRC_REPAIR_MESSAGE = [
"rust-analyzer exited while loading Rust standard library sources.",
"",
"Repair rust-src for the active toolchain:",
" rustup component remove rust-src",
" rustup component add rust-src",
];
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function formatKnownLspStartupFailure(error: unknown): string | null {
if (!(error instanceof LspProcessExitedError)) return null;
if (error.serverId !== "rust") return null;
const details = error.stderrTail ?? error.message;
const lowerDetails = details.toLowerCase();
const isRustSrcFailure =
lowerDetails.includes("rust-src") &&
(lowerDetails.includes("failed to install component") ||
lowerDetails.includes("detected conflict") ||
lowerDetails.includes("can't load standard library") ||
lowerDetails.includes("try installing") ||
lowerDetails.includes("sysroot"));
if (!isRustSrcFailure) return null;
return [...RUST_SRC_REPAIR_MESSAGE, "", "Original stderr tail:", details].join("\n");
}
export function handleMissingDependencyError(error: unknown): string | null {
const knownStartupFailure = formatKnownLspStartupFailure(error);
if (knownStartupFailure) return knownStartupFailure;
const message = errorMessage(error);
return message.includes("NOT INSTALLED") || message.includes("No LSP server configured") ? message : null;
}
@@ -0,0 +1,132 @@
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { uriToPath } from "./formatters.js";
import type { TextEdit, WorkspaceEdit } from "./types.js";
export interface ApplyResult {
success: boolean;
filesModified: string[];
totalEdits: number;
errors: string[];
}
interface FileApplyResult {
success: boolean;
editCount: number;
error?: string;
}
function applyTextEditsToFile(filePath: string, edits: TextEdit[]): FileApplyResult {
try {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
if (startLine === endLine) {
const line = lines[startLine] ?? "";
lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar);
} else {
const firstLine = lines[startLine] ?? "";
const lastLine = lines[endLine] ?? "";
const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar);
lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"));
}
}
writeFileSync(filePath, lines.join("\n"), "utf-8");
return { success: true, editCount: edits.length };
} catch (err) {
return {
success: false,
editCount: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
if (!edit) {
return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] };
}
const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] };
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri);
const applyResult = applyTextEditsToFile(filePath, edits);
if (applyResult.success) {
result.filesModified.push(filePath);
result.totalEdits += applyResult.editCount;
} else {
result.success = false;
result.errors.push(`${filePath}: ${applyResult.error}`);
}
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if (!("kind" in change)) {
const filePath = uriToPath(change.textDocument.uri);
const applyResult = applyTextEditsToFile(filePath, change.edits);
if (applyResult.success) {
result.filesModified.push(filePath);
result.totalEdits += applyResult.editCount;
} else {
result.success = false;
result.errors.push(`${filePath}: ${applyResult.error}`);
}
continue;
}
if (change.kind === "create") {
try {
const filePath = uriToPath(change.uri);
writeFileSync(filePath, "", "utf-8");
result.filesModified.push(filePath);
} catch (err) {
result.success = false;
result.errors.push(`Create ${change.uri}: ${String(err)}`);
}
} else if (change.kind === "rename") {
try {
const oldPath = uriToPath(change.oldUri);
const newPath = uriToPath(change.newUri);
const content = readFileSync(oldPath, "utf-8");
writeFileSync(newPath, content, "utf-8");
unlinkSync(oldPath);
result.filesModified.push(newPath);
} catch (err) {
result.success = false;
result.errors.push(`Rename ${change.oldUri}: ${String(err)}`);
}
} else if (change.kind === "delete") {
try {
const filePath = uriToPath(change.uri);
unlinkSync(filePath);
result.filesModified.push(filePath);
} catch (err) {
result.success = false;
result.errors.push(`Delete ${change.uri}: ${String(err)}`);
}
}
}
}
return result;
}
@@ -0,0 +1,141 @@
import { createInterface } from "node:readline";
import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS, type TextContent } from "./tools.js";
export type JsonRpcId = string | number | null;
export interface McpToolDescriptor {
name: string;
title: string;
description: string;
inputSchema: unknown;
}
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
export interface JsonRpcResult {
capabilities?: Record<string, unknown>;
serverInfo?: Record<string, unknown>;
protocolVersion?: string;
tools?: McpToolDescriptor[];
content?: TextContent[];
isError?: boolean;
[key: string]: unknown;
}
export interface JsonRpcResponse {
jsonrpc: "2.0";
id: JsonRpcId;
result?: JsonRpcResult;
error?: JsonRpcError;
}
const SERVER_NAME = "lsp";
const SERVER_VERSION = "0.1.0";
export async function handleLspMcpRequest(input: unknown): Promise<JsonRpcResponse | undefined> {
if (!isRecord(input)) {
return errorResponse(null, -32600, "Invalid Request");
}
const id = jsonRpcId(input["id"]);
const method = input["method"];
if (method === "notifications/initialized") return undefined;
if (method === "ping") return successResponse(id, {});
if (method === "initialize") {
const protocolVersion = requestedProtocolVersion(input["params"]);
return successResponse(id, {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
protocolVersion,
});
}
if (method === "tools/list") {
return successResponse(id, { tools: LSP_MCP_TOOLS.map(describeTool) });
}
if (method === "tools/call") {
return handleToolCall(id, input["params"]);
}
return errorResponse(id, -32601, `Method not found: ${String(method)}`);
}
export async function runMcpStdioServer(
input: NodeJS.ReadableStream = process.stdin,
output: NodeJS.WritableStream = process.stdout,
): Promise<void> {
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of lines) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
continue;
}
const response = await handleLspMcpRequest(parsed);
if (response) output.write(`${JSON.stringify(response)}\n`);
}
}
async function handleToolCall(id: JsonRpcId, params: unknown): Promise<JsonRpcResponse> {
if (!isRecord(params) || typeof params["name"] !== "string") {
return errorResponse(id, -32602, "tools/call requires params.name");
}
try {
const result = await executeLspTool(params["name"], coerceToolArguments(params["arguments"]));
return successResponse(id, {
content: result.content,
isError: result.isError ?? false,
details: result.details,
});
} catch (error) {
return successResponse(id, {
content: [{ type: "text", text: messageFromError(error) }],
isError: true,
});
}
}
function describeTool(tool: (typeof LSP_MCP_TOOLS)[number]): McpToolDescriptor {
return {
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
};
}
function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse {
return { jsonrpc: "2.0", id, result };
}
function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
}
function requestedProtocolVersion(params: unknown): string {
if (!isRecord(params) || typeof params["protocolVersion"] !== "string") return "2024-11-05";
return params["protocolVersion"];
}
function jsonRpcId(value: unknown): JsonRpcId {
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,669 @@
import { resolve } from "node:path";
import { isDirectoryPath, type WithLspClientOptions, withLspClient } from "./lsp/client-wrapper.js";
import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES, DEFAULT_MAX_SYMBOLS } from "./lsp/constants.js";
import { aggregateDiagnosticsForDirectory } from "./lsp/directory-diagnostics.js";
import {
filterDiagnosticsBySeverity,
formatApplyResult,
formatDiagnostic,
formatDocumentSymbol,
formatLocation,
formatPrepareRenameResult,
formatSymbolInfo,
} from "./lsp/formatters.js";
import { inferExtensionFromDirectory } from "./lsp/infer-extension.js";
import { getLspManager } from "./lsp/manager.js";
import { getAllServers } from "./lsp/server-resolution.js";
import type {
Diagnostic,
DocumentSymbol,
Location,
LocationLink,
PrepareRenameDefaultBehavior,
PrepareRenameResult,
Range,
SeverityFilter,
SymbolInfo,
WorkspaceEdit,
} from "./lsp/types.js";
import { handleMissingDependencyError } from "./lsp/utils.js";
import { type ApplyResult, applyWorkspaceEdit } from "./lsp/workspace-edit.js";
export interface TextContent {
type: "text";
text: string;
}
export interface ToolExecutionResult {
content: TextContent[];
isError?: boolean;
details?: unknown;
}
export interface JsonSchema {
type: string;
description?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
}
export interface LspMcpTool {
name: string;
aliases?: string[];
title: string;
description: string;
inputSchema: JsonSchema;
execute(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult>;
}
export interface LspDiagnosticsDetails {
filePath: string;
severity: SeverityFilter;
mode: "file" | "directory";
diagnostics: Array<{ file: string; diagnostic: Diagnostic }>;
totalDiagnostics: number;
truncated: boolean;
error?: string;
errorKind?: "missing_dependency" | "no_files" | "invalid_path";
}
export interface LspGotoDefinitionDetails {
filePath: string;
line: number;
character: number;
locations: Array<Location | LocationLink>;
error?: string;
errorKind?: "missing_dependency";
}
export interface LspFindReferencesDetails {
filePath: string;
line: number;
character: number;
references: Location[];
totalReferences: number;
truncated: boolean;
error?: string;
errorKind?: "missing_dependency";
}
export interface LspSymbolsDetails {
filePath: string;
scope: "document" | "workspace";
query?: string;
symbols: Array<DocumentSymbol | SymbolInfo>;
totalSymbols: number;
truncated: boolean;
error?: string;
errorKind?: "missing_dependency" | "missing_query";
}
export interface LspPrepareRenameDetails {
filePath: string;
line: number;
character: number;
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null;
error?: string;
errorKind?: "missing_dependency";
}
export interface LspRenameDetails {
filePath: string;
line: number;
character: number;
newName: string;
apply: ApplyResult | null;
edit: WorkspaceEdit | null;
error?: string;
errorKind?: "missing_dependency";
}
const objectSchema = (properties: Record<string, JsonSchema>, required: string[] = []): JsonSchema => ({
type: "object",
properties,
required,
});
function text(text: string, details?: unknown, isError = false): ToolExecutionResult {
return { content: [{ type: "text", text }], details, isError };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireString(params: Record<string, unknown>, key: string): string {
const value = params[key];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing required string parameter '${key}'`);
}
return value;
}
function optionalString(params: Record<string, unknown>, key: string): string | undefined {
const value = params[key];
return typeof value === "string" ? value : undefined;
}
function requireNumber(params: Record<string, unknown>, key: string): number {
const value = params[key];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`Missing required number parameter '${key}'`);
}
return value;
}
function optionalNumber(params: Record<string, unknown>, key: string): number | undefined {
const value = params[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function optionalBoolean(params: Record<string, unknown>, key: string): boolean | undefined {
const value = params[key];
return typeof value === "boolean" ? value : undefined;
}
function isSeverityFilter(value: unknown): value is SeverityFilter {
return value === "error" || value === "warning" || value === "information" || value === "hint" || value === "all";
}
function severityFilter(params: Record<string, unknown>): SeverityFilter {
const value = params["severity"];
if (isSeverityFilter(value)) return value;
return "all";
}
function clientOptions(signal: AbortSignal | undefined): WithLspClientOptions {
return signal === undefined ? {} : { signal };
}
function asDiagnosticArray(result: { items?: Diagnostic[] } | Diagnostic[] | null | undefined): Diagnostic[] {
if (!result) return [];
if (Array.isArray(result)) return result;
return result.items ?? [];
}
function isDocumentSymbol(symbol: DocumentSymbol | SymbolInfo): symbol is DocumentSymbol {
return "range" in symbol;
}
async function executeLspStatus(): Promise<ToolExecutionResult> {
const servers = getAllServers();
const snapshots = getLspManager().getSnapshot();
const installed = servers.filter((server) => server.installed && !server.disabled);
const configuredLines = servers.map((server) => {
const state = server.disabled ? "disabled" : server.installed ? "installed" : "missing";
return `- ${server.id}: ${state}; source=${server.source}; extensions=${server.extensions.join(", ")}`;
});
const activeLines = snapshots.map((snapshot) => {
const state = snapshot.alive ? (snapshot.isInitializing ? "initializing" : "alive") : "dead";
return `- ${snapshot.serverId}: ${state}; root=${snapshot.root}; refs=${snapshot.refCount}`;
});
const lines = [
`Configured LSP servers: ${servers.length}`,
`Installed LSP servers: ${installed.length}`,
"",
...configuredLines,
"",
`Active LSP clients: ${snapshots.length}`,
...activeLines,
];
return text(lines.join("\n"), { servers, snapshots });
}
export async function executeLspDiagnostics(
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const severity = severityFilter(params);
try {
const absPath = resolve(filePath);
if (isDirectoryPath(absPath)) {
const extension = inferExtensionFromDirectory(absPath);
if (!extension) {
const message = `No supported source files found in directory: ${absPath}`;
const details: LspDiagnosticsDetails = {
filePath,
severity,
mode: "directory",
diagnostics: [],
totalDiagnostics: 0,
truncated: false,
error: message,
errorKind: "no_files",
};
return text(message, details);
}
const output = await aggregateDiagnosticsForDirectory(absPath, extension, severity);
const details: LspDiagnosticsDetails = {
filePath,
severity,
mode: "directory",
diagnostics: [],
totalDiagnostics: 0,
truncated: false,
};
return text(output, details);
}
const result = await withLspClient(
filePath,
async (client) => client.diagnostics(filePath),
"diagnostics",
clientOptions(signal),
);
const diagnostics = filterDiagnosticsBySeverity(asDiagnosticArray(result), severity);
const total = diagnostics.length;
const truncated = total > DEFAULT_MAX_DIAGNOSTICS;
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics;
const output =
total === 0
? "No diagnostics found"
: [
...(truncated ? [`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`] : []),
...limited.map(formatDiagnostic),
].join("\n");
const details: LspDiagnosticsDetails = {
filePath,
severity,
mode: "file",
diagnostics: diagnostics.map((diagnostic) => ({ file: absPath, diagnostic })),
totalDiagnostics: total,
truncated,
};
return text(output, details);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
const details: LspDiagnosticsDetails = {
filePath,
severity,
mode: "file",
diagnostics: [],
totalDiagnostics: 0,
truncated: false,
error: message,
errorKind: "missing_dependency",
};
return text(message, details);
}
throw error;
}
}
async function executeLspGotoDefinition(
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const line = requireNumber(params, "line");
const character = requireNumber(params, "character");
try {
const result = await withLspClient(
filePath,
async (client) => client.definition(filePath, line, character),
"definition",
clientOptions(signal),
);
const locations = !result ? [] : Array.isArray(result) ? result : [result];
const details: LspGotoDefinitionDetails = { filePath, line, character, locations };
if (locations.length === 0) return text("No definition found", details);
return text(locations.map(formatLocation).join("\n"), details);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
return text(message, {
filePath,
line,
character,
locations: [],
error: message,
errorKind: "missing_dependency",
});
}
throw error;
}
}
async function executeLspFindReferences(
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const line = requireNumber(params, "line");
const character = requireNumber(params, "character");
const includeDeclaration = optionalBoolean(params, "includeDeclaration") ?? true;
try {
const result = await withLspClient(
filePath,
async (client) => client.references(filePath, line, character, includeDeclaration),
"references",
clientOptions(signal),
);
const references = Array.isArray(result) ? result : [];
const total = references.length;
const truncated = total > DEFAULT_MAX_REFERENCES;
const limited = truncated ? references.slice(0, DEFAULT_MAX_REFERENCES) : references;
const details: LspFindReferencesDetails = {
filePath,
line,
character,
references,
totalReferences: total,
truncated,
};
if (total === 0) return text("No references found", details);
const output = [
...(truncated ? [`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`] : []),
...limited.map(formatLocation),
].join("\n");
return text(output, details);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
return text(message, {
filePath,
line,
character,
references: [],
totalReferences: 0,
truncated: false,
error: message,
errorKind: "missing_dependency",
});
}
throw error;
}
}
async function executeLspSymbols(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const rawScope = optionalString(params, "scope") ?? "document";
const scope = rawScope === "workspace" ? "workspace" : "document";
const limit = Math.min(optionalNumber(params, "limit") ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS);
try {
if (scope === "workspace") {
const query = optionalString(params, "query");
if (!query) {
const message = "Error: 'query' is required for workspace scope";
return text(message, {
filePath,
scope,
symbols: [],
totalSymbols: 0,
truncated: false,
error: message,
errorKind: "missing_query",
});
}
const symbols = await withLspClient(
filePath,
async (client) => client.workspaceSymbols(query),
"workspaceSymbols",
clientOptions(signal),
);
return formatSymbolsResult(filePath, scope, symbols, limit, query);
}
const symbols = await withLspClient(
filePath,
async (client) => client.documentSymbols(filePath),
"documentSymbols",
clientOptions(signal),
);
return formatSymbolsResult(filePath, scope, symbols, limit);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
const query = optionalString(params, "query");
return text(message, {
filePath,
scope,
symbols: [],
totalSymbols: 0,
truncated: false,
error: message,
errorKind: "missing_dependency",
...(query === undefined ? {} : { query }),
});
}
throw error;
}
}
function formatSymbolsResult(
filePath: string,
scope: "document" | "workspace",
symbols: Array<DocumentSymbol | SymbolInfo>,
limit: number,
query?: string,
): ToolExecutionResult {
const total = symbols.length;
const truncated = total > limit;
const limited = truncated ? symbols.slice(0, limit) : symbols;
const details: LspSymbolsDetails = {
filePath,
scope,
symbols,
totalSymbols: total,
truncated,
...(query === undefined ? {} : { query }),
};
if (total === 0) return text("No symbols found", details);
const lines: string[] = [];
if (truncated) lines.push(`Found ${total} symbols (showing first ${limit}):`);
const documentSymbols = limited.filter(isDocumentSymbol);
if (documentSymbols.length === limited.length) {
lines.push(...documentSymbols.map((symbol) => formatDocumentSymbol(symbol)));
} else {
lines.push(...limited.filter((symbol): symbol is SymbolInfo => !isDocumentSymbol(symbol)).map(formatSymbolInfo));
}
return text(lines.join("\n"), details);
}
async function executeLspPrepareRename(
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const line = requireNumber(params, "line");
const character = requireNumber(params, "character");
try {
const result = await withLspClient(
filePath,
async (client) => client.prepareRename(filePath, line, character),
"prepareRename",
clientOptions(signal),
);
const details: LspPrepareRenameDetails = { filePath, line, character, result };
return text(formatPrepareRenameResult(result), details);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
return text(message, {
filePath,
line,
character,
result: null,
error: message,
errorKind: "missing_dependency",
});
}
throw error;
}
}
async function executeLspRename(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult> {
const filePath = requireString(params, "filePath");
const line = requireNumber(params, "line");
const character = requireNumber(params, "character");
const newName = requireString(params, "newName");
try {
const edit = await withLspClient(
filePath,
async (client) => client.rename(filePath, line, character, newName),
"rename",
clientOptions(signal),
);
const apply = applyWorkspaceEdit(edit);
const details: LspRenameDetails = { filePath, line, character, newName, apply, edit };
return text(formatApplyResult(apply), details, !apply.success);
} catch (error) {
const message = handleMissingDependencyError(error);
if (message) {
return text(message, {
filePath,
line,
character,
newName,
apply: null,
edit: null,
error: message,
errorKind: "missing_dependency",
});
}
throw error;
}
}
export async function executeLspTool(
name: string,
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolExecutionResult> {
const tool = LSP_MCP_TOOLS.find((candidate) => matchesToolName(candidate, name));
if (!tool) throw new Error(`Unknown LSP tool: ${name}`);
return tool.execute(params, signal);
}
function matchesToolName(tool: LspMcpTool, name: string): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false);
}
export function coerceToolArguments(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export const LSP_MCP_TOOLS: LspMcpTool[] = [
{
name: "status",
aliases: ["lsp_status"],
title: "LSP Status",
description: "List configured and active LSP servers without starting a new language server.",
inputSchema: objectSchema({}),
execute: executeLspStatus,
},
{
name: "diagnostics",
aliases: ["lsp_diagnostics"],
title: "LSP Diagnostics",
description: "Get errors, warnings, and hints for a source file or directory.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "File or directory path to check." },
severity: {
type: "string",
enum: ["error", "warning", "information", "hint", "all"],
description: "Severity filter. Defaults to all.",
},
},
["filePath"],
),
execute: executeLspDiagnostics,
},
{
name: "goto_definition",
aliases: ["lsp_goto_definition"],
title: "LSP Goto Definition",
description: "Find where a symbol is defined.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "Source file containing the symbol." },
line: { type: "number", description: "1-based line number." },
character: { type: "number", description: "0-based column." },
},
["filePath", "line", "character"],
),
execute: executeLspGotoDefinition,
},
{
name: "find_references",
aliases: ["lsp_find_references"],
title: "LSP Find References",
description: "Find references of a symbol across the workspace.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "Source file containing the symbol." },
line: { type: "number", description: "1-based line number." },
character: { type: "number", description: "0-based column." },
includeDeclaration: { type: "boolean", description: "Include the declaration. Defaults to true." },
},
["filePath", "line", "character"],
),
execute: executeLspFindReferences,
},
{
name: "symbols",
aliases: ["lsp_symbols"],
title: "LSP Symbols",
description: "List document symbols or search workspace symbols.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "File path used as LSP context." },
scope: {
type: "string",
enum: ["document", "workspace"],
description: "Use document for file outline or workspace for project-wide search.",
},
query: { type: "string", description: "Workspace symbol query." },
limit: { type: "number", description: "Maximum number of symbols to return." },
},
["filePath", "scope"],
),
execute: executeLspSymbols,
},
{
name: "prepare_rename",
aliases: ["lsp_prepare_rename"],
title: "LSP Prepare Rename",
description: "Check whether a symbol can be renamed at a position.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "Source file path." },
line: { type: "number", description: "1-based line number." },
character: { type: "number", description: "0-based column." },
},
["filePath", "line", "character"],
),
execute: executeLspPrepareRename,
},
{
name: "rename",
aliases: ["lsp_rename"],
title: "LSP Rename",
description: "Rename a symbol across the workspace and apply the returned workspace edit.",
inputSchema: objectSchema(
{
filePath: { type: "string", description: "Source file path." },
line: { type: "number", description: "1-based line number." },
character: { type: "number", description: "0-based column." },
newName: { type: "string", description: "New symbol name." },
},
["filePath", "line", "character", "newName"],
),
execute: executeLspRename,
},
];
@@ -0,0 +1,135 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getConfigPaths, getMergedServers } from "../src/lsp/config-loader.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("config loader", () => {
it("uses Codex config locations instead of pi config locations", () => {
const paths = getConfigPaths();
const expectedSuffix = join(".codex", "lsp-client.json");
const piMarker = `${sep}.pi${sep}`;
expect(paths.project.endsWith(expectedSuffix)).toBe(true);
expect(paths.user.endsWith(expectedSuffix)).toBe(true);
expect(paths.project).not.toContain(piMarker);
expect(paths.user).not.toContain(piMarker);
});
it("supports project and user config path overrides via environment variables", () => {
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = "config/lsp-opencode.json";
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = ".opencode/lsp.json";
try {
const paths = getConfigPaths();
expect(paths.project).toBe(join(process.cwd(), "config", "lsp-opencode.json"));
expect(paths.user).toBe(join(process.env["HOME"] ?? "", ".opencode", "lsp.json"));
} finally {
if (previousProject === undefined) {
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
}
if (previousUser === undefined) {
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
}
}
});
it("keeps absolute override paths unchanged", () => {
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
const absoluteProject = join(process.cwd(), "overrides", "project.json");
const absoluteUser = join(process.cwd(), "overrides", "user.json");
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = absoluteProject;
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = absoluteUser;
try {
const paths = getConfigPaths();
expect(paths.project).toBe(absoluteProject);
expect(paths.user).toBe(absoluteUser);
} finally {
if (previousProject === undefined) {
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
}
if (previousUser === undefined) {
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
}
}
});
it("#given one invalid LSP config entry #when merging servers #then keeps valid sibling entries", () => {
// given
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
const root = mkdtempSync(join(tmpdir(), "lsp-tools-config-"));
tempDirectories.push(root);
const projectConfig = join(root, "project.json");
const userConfig = join(root, "user.json");
mkdirSync(root, { recursive: true });
writeFileSync(
projectConfig,
JSON.stringify({
lsp: {
valid: { command: ["valid-lsp", "--stdio"], extensions: [".valid"], priority: 7 },
invalid: "not an object",
},
}),
);
writeFileSync(userConfig, JSON.stringify({ lsp: {} }));
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = projectConfig;
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = userConfig;
try {
// when
const servers = getMergedServers();
// then
expect(servers).toContainEqual(
expect.objectContaining({
id: "valid",
command: ["valid-lsp", "--stdio"],
extensions: [".valid"],
priority: 7,
source: "project",
}),
);
expect(servers.some((server) => server.id === "invalid")).toBe(false);
} finally {
if (previousProject === undefined) {
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
}
if (previousUser === undefined) {
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
} else {
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
}
}
});
});
@@ -0,0 +1,32 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { collectFilesWithExtension } from "../src/lsp/directory-diagnostics.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("collectFilesWithExtension", () => {
it("#given more matching files than max #when collecting diagnostics inputs #then traversal returns only capped files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-lsp-directory-"));
tempDirectories.push(root);
mkdirSync(join(root, "src"), { recursive: true });
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(root, "src", `file-${index}.ts`), `export const value${index} = ${index};\n`);
}
// when
const files = collectFilesWithExtension(root, ".ts", 2);
// then
expect(files).toHaveLength(2);
});
});
@@ -0,0 +1 @@
value: str = 1
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { filterDiagnosticsBySeverity } from "../src/lsp/formatters.js";
import type { Diagnostic } from "../src/lsp/types.js";
const range = {
start: { line: 0, character: 0 },
end: { line: 0, character: 1 },
};
function diagnostic(message: string, severity?: number): Diagnostic {
return severity === undefined ? { range, message } : { range, message, severity };
}
describe("filterDiagnosticsBySeverity", () => {
it("#given all severity filter #when filtering diagnostics #then returns the original diagnostics", () => {
// given
const diagnostics = [diagnostic("syntax", 1), diagnostic("note", 3)];
// when
const filtered = filterDiagnosticsBySeverity(diagnostics, "all");
// then
expect(filtered).toBe(diagnostics);
});
it("#given mixed severities #when filtering diagnostics #then returns only matching diagnostics", () => {
// given
const diagnostics = [
diagnostic("syntax", 1),
diagnostic("lint", 2),
diagnostic("note", 3),
diagnostic("hint", 4),
diagnostic("unknown"),
];
// when / then
expect(filterDiagnosticsBySeverity(diagnostics, "error")).toEqual([diagnostics[0]]);
expect(filterDiagnosticsBySeverity(diagnostics, "warning")).toEqual([diagnostics[1]]);
expect(filterDiagnosticsBySeverity(diagnostics, "information")).toEqual([diagnostics[2]]);
expect(filterDiagnosticsBySeverity(diagnostics, "hint")).toEqual([diagnostics[3]]);
});
});

Some files were not shown because too many files have changed in this diff Show More