vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+40
@@ -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
|
||||
+27
@@ -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
|
||||
+45
@@ -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
|
||||
+19
@@ -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
|
||||
+47
@@ -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
|
||||
+51
@@ -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
|
||||
|
||||
[](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [](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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
@@ -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
|
||||
|
||||
[](https://github.com/code-yeongyu/codex-lsp/actions/workflows/ci.yml) [](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
|
||||
+1
@@ -0,0 +1 @@
|
||||
* @code-yeongyu
|
||||
Vendored
+26
@@ -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
|
||||
Vendored
+19
@@ -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
|
||||
Vendored
+45
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
@@ -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
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Validation
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
+47
@@ -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
|
||||
Vendored
+51
@@ -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
|
||||
|
||||
[](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [](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;
|
||||
});
|
||||
+5
@@ -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}`);
|
||||
}
|
||||
+146
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+188
@@ -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;
|
||||
+152
@@ -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;
|
||||
}
|
||||
+141
@@ -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");
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
+296
@@ -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));
|
||||
}
|
||||
+172
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+163
@@ -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"],
|
||||
};
|
||||
+57
@@ -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;
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
+132
@@ -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,
|
||||
},
|
||||
];
|
||||
+135
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+32
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
value: str = 1
|
||||
+43
@@ -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
Reference in New Issue
Block a user