fix(omo-codex): reuse shared lsp mcp

This commit is contained in:
YeonGyu-Kim
2026-05-28 15:30:24 +09:00
parent 4f75a56ce5
commit 615ed40ba3
79 changed files with 378 additions and 5510 deletions
+1 -1
View File
@@ -65,7 +65,7 @@
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test",
"test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
"test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
"test:windows-codex": "bun run test:codex",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
},
@@ -1,4 +0,0 @@
[submodule "packages/lsp-tools-mcp"]
path = packages/lsp-tools-mcp
url = https://github.com/code-yeongyu/lsp-tools-mcp.git
branch = main
@@ -2,7 +2,7 @@
"mcpServers": {
"lsp": {
"command": "node",
"args": ["./packages/lsp-tools-mcp/dist/cli.js", "mcp"],
"args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"],
"cwd": "."
}
}
@@ -2,6 +2,8 @@
## Unreleased
- Reuse the repository-level `packages/lsp-tools-mcp` package instead of carrying a second copy under `components/lsp/packages`.
## 0.2.0
- Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp).
@@ -6,7 +6,7 @@ Codex plugin that ports the standalone LSP runtime from [`pi-lsp-client`](https:
## 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/`.
The LSP runtime moved to [`lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp) and is consumed from this repository's root `packages/lsp-tools-mcp/` package.
- `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.
@@ -75,7 +75,7 @@ The plugin ships:
- `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 runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:../../../../lsp-tools-mcp`, so marketplace builds reuse the root package instead of carrying a second copy under this component.
The hook command is:
@@ -86,14 +86,13 @@ node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
The MCP command is:
```bash
node ./packages/lsp-tools-mcp/dist/cli.js mcp
node ../../../../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 run bootstrap # installs + builds the root packages/lsp-tools-mcp package
npm install
npm test
npm run typecheck
@@ -101,7 +100,7 @@ npm run check
npm pack --dry-run
```
The `bootstrap` script installs and builds the `lsp-tools-mcp` git submodule so
The `bootstrap` script installs and builds the root `lsp-tools-mcp` package so
`@code-yeongyu/lsp-tools-mcp/dist/*.js` is available for the codex-lsp build.
Smoke-test the hook:
@@ -36,21 +36,21 @@
"CHANGELOG.md"
],
"scripts": {
"bootstrap": "node scripts/bootstrap-submodule.mjs",
"prebuild": "node scripts/bootstrap-submodule.mjs",
"bootstrap": "node scripts/build-lsp-tools.mjs",
"prebuild": "node scripts/build-lsp-tools.mjs",
"build": "tsc -p tsconfig.build.json",
"pretest": "node scripts/bootstrap-submodule.mjs",
"pretest": "node scripts/build-lsp-tools.mjs",
"test": "vitest --run",
"test:watch": "vitest",
"pretypecheck": "node scripts/bootstrap-submodule.mjs",
"pretypecheck": "node scripts/build-lsp-tools.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check src test",
"lint:fix": "biome check --write src test",
"precheck": "node scripts/bootstrap-submodule.mjs",
"precheck": "node scripts/build-lsp-tools.mjs",
"check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json"
},
"dependencies": {
"@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp"
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
@@ -1,13 +0,0 @@
# 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 @@
* @code-yeongyu
@@ -1,26 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,45 +0,0 @@
{
"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"
}
]
}
@@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
@@ -1,11 +0,0 @@
## Summary
-
## Validation
-
## Notes
-
@@ -1,47 +0,0 @@
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
@@ -1,51 +0,0 @@
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 }}
@@ -1,6 +0,0 @@
node_modules/
*.log
.env
.DS_Store
coverage/
.vitest/
@@ -1,20 +0,0 @@
# 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)
@@ -1,21 +0,0 @@
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.
@@ -1,3 +0,0 @@
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.
@@ -1,102 +0,0 @@
# lsp-tools-mcp
[![ci](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Standalone Language Server Protocol tools exposed as a stdio MCP server.
## Used By
This repository is the upstream source of truth for two downstream plugins. Both consume it as a git submodule:
| Project | Path | Role |
|---------|------|------|
| **[codex-lsp](https://github.com/code-yeongyu/codex-lsp)** | `packages/lsp-tools-mcp/` | Codex plugin that ships these LSP MCP tools plus a Codex-specific PostToolUse diagnostics hook. |
| **[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)** (a.k.a. `oh-my-opencode`) | `vendor/lsp-tools-mcp/` | OpenCode plugin that registers this server as a built-in Tier-1 stdio MCP. Exposes `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, and `lsp_status` to all agents. |
If you fix or extend the LSP runtime here, both downstreams pick up the change by bumping the submodule pointer. Do not fork the runtime into a downstream; land changes here instead.
## Quick Start
```bash
npm install
npm run check
npm test
npm run build
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp
```
## MCP Tools
This server exposes the following tools:
- `lsp.status`
- `lsp.diagnostics`
- `lsp.goto_definition`
- `lsp.find_references`
- `lsp.symbols`
- `lsp.prepare_rename`
- `lsp.rename`
Tool aliases are also available for compatibility:
- `lsp_status`
- `lsp_diagnostics`
- `lsp_goto_definition`
- `lsp_find_references`
- `lsp_symbols`
- `lsp_prepare_rename`
- `lsp_rename`
When an MCP host registers this server under the name `lsp` (the default in both downstreams), the tools are exposed to agents as `lsp_status`, `lsp_diagnostics`, and so on, matching the alias names above.
## Configuration
Default config paths (matches codex-lsp's historical layout):
- Project: `.codex/lsp-client.json`
- User: `~/.codex/lsp-client.json`
Path overrides via environment variables:
- `LSP_TOOLS_MCP_PROJECT_CONFIG`
- `LSP_TOOLS_MCP_USER_CONFIG`
Examples (oh-my-openagent points the project config at `.opencode/lsp.json` via the env var):
```bash
LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json node dist/cli.js mcp
LSP_TOOLS_MCP_USER_CONFIG=.opencode/lsp.json node dist/cli.js mcp
```
Example config file:
```json
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".js", ".jsx"]
}
}
}
```
## Architecture
- `src/lsp/*` standalone LSP runtime (process management, JSON-RPC transport, configuration, diagnostics, workspace edits)
- `src/tools.ts` MCP tool definitions and handlers
- `src/mcp.ts` stdio MCP server entry and registration
- `src/cli.ts` standalone CLI entry (`mcp` subcommand only)
## Local Development
```bash
npm install
npm run check
npm test
npm pack --dry-run
```
## License
[MIT](LICENSE)
@@ -1,48 +0,0 @@
{
"$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"
}
}
}
}
]
}
@@ -1,52 +0,0 @@
{
"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"
}
}
@@ -1,34 +0,0 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { disposeDefaultLspManager } from "./lsp/manager.js";
import { runMcpStdioServer } from "./mcp.js";
import { writeMcpLifecycleLog } from "./mcp-lifecycle-log.js";
async function main(): Promise<void> {
const [command = "mcp"] = argv.slice(2);
try {
if (command === "mcp") {
await runMcpStdioServer(process.stdin, process.stdout, {
log: writeMcpLifecycleLog,
onIdleTimeout: async () => {
await disposeDefaultLspManager();
process.exit(0);
},
});
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;
});
@@ -1,5 +0,0 @@
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}`);
}
@@ -1,146 +0,0 @@
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);
}
@@ -1,170 +0,0 @@
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,
});
}
}
@@ -1,188 +0,0 @@
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;
}
@@ -1,69 +0,0 @@
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));
}
}
@@ -1,11 +0,0 @@
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;
@@ -1,152 +0,0 @@
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");
}
@@ -1,63 +0,0 @@
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;
}
@@ -1,141 +0,0 @@
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");
}
@@ -1,65 +0,0 @@
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;
}
@@ -1,296 +0,0 @@
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));
}
@@ -1,172 +0,0 @@
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";
}
@@ -1,369 +0,0 @@
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();
}
}
@@ -1,21 +0,0 @@
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);
}
};
}
@@ -1,202 +0,0 @@
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);
}
@@ -1,163 +0,0 @@
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"],
};
@@ -1,57 +0,0 @@
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;
}
@@ -1,104 +0,0 @@
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;
}
@@ -1,285 +0,0 @@
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";
}
@@ -1,126 +0,0 @@
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";
@@ -1,40 +0,0 @@
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;
}
@@ -1,132 +0,0 @@
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;
}
@@ -1,36 +0,0 @@
import { appendFileSync, renameSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
type LogFieldValue = boolean | number | string | null;
const LOG_FILE_NAME = "omo-codex-lsp-mcp.log";
const MAX_LOG_BYTES = 5 * 1024 * 1024;
export function mcpLifecycleLogPath(): string {
return join(tmpdir(), LOG_FILE_NAME);
}
export function writeMcpLifecycleLog(event: string, fields: Record<string, LogFieldValue> = {}): void {
const path = mcpLifecycleLogPath();
try {
rotateLogIfNeeded(path);
appendFileSync(
path,
`${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`,
);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
function rotateLogIfNeeded(path: string): void {
try {
if (statSync(path).size < MAX_LOG_BYTES) return;
renameSync(path, `${path}.1`);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
@@ -1,188 +0,0 @@
import { createInterface } from "node:readline";
import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS, type TextContent } from "./tools.js";
export type JsonRpcId = string | number | null;
export type McpLifecycleLog = (event: string, fields?: Record<string, boolean | number | string | null>) => void;
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;
}
export interface McpStdioServerOptions {
readonly idleTimeoutMs?: number;
readonly onIdleTimeout?: () => void | Promise<void>;
readonly log?: McpLifecycleLog;
}
const SERVER_NAME = "lsp";
const SERVER_VERSION = "0.1.0";
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
const noopLog: McpLifecycleLog = () => {};
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,
options: McpStdioServerOptions = {},
): Promise<void> {
const log = options.log ?? noopLog;
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
let idleTimer: NodeJS.Timeout | null = null;
let closed = false;
const clearIdleTimer = () => {
if (idleTimer === null) return;
clearTimeout(idleTimer);
idleTimer = null;
};
const armIdleTimer = () => {
clearIdleTimer();
if (idleTimeoutMs <= 0) return;
idleTimer = setTimeout(() => {
closed = true;
log("idle_timeout", { idle_timeout_ms: idleTimeoutMs });
void options.onIdleTimeout?.();
}, idleTimeoutMs);
idleTimer.unref();
};
log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs });
armIdleTimer();
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
try {
for await (const line of lines) {
if (closed) break;
armIdleTimer();
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
log("parse_error", { message: messageFromError(error) });
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
continue;
}
const id = isRecord(parsed) ? jsonRpcId(parsed["id"]) : null;
const method = isRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null;
log("request", { id: id === null ? null : String(id), method });
const response = await handleLspMcpRequest(parsed);
if (response) {
output.write(`${JSON.stringify(response)}\n`);
log("response", { id: String(response.id), method, is_error: response.error !== undefined });
}
}
} finally {
clearIdleTimer();
log("stdio_stopped");
}
}
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);
}
@@ -1,669 +0,0 @@
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,
},
];
@@ -1,135 +0,0 @@
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;
}
}
});
});
@@ -1,32 +0,0 @@
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 @@
value: str = 1
@@ -1,43 +0,0 @@
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]]);
});
});
@@ -1,74 +0,0 @@
import { LspClient } from "../../src/lsp/client.js";
import type { ResolvedServer } from "../../src/lsp/types.js";
export interface FakeLspClientOptions {
startDelayMs?: number;
initDelayMs?: number;
failStart?: boolean;
failInitialize?: boolean;
stopDelayMs?: number;
startsAlive?: boolean;
}
export class FakeLspClient extends LspClient {
private aliveFlag: boolean;
startCallCount = 0;
initializeCallCount = 0;
stopCallCount = 0;
constructor(
root: string,
server: ResolvedServer,
private readonly opts: FakeLspClientOptions = {},
) {
super(root, server);
this.aliveFlag = opts.startsAlive !== false;
}
override async start(): Promise<void> {
this.startCallCount++;
if (this.opts.startDelayMs !== undefined) {
await new Promise((resolve) => setTimeout(resolve, this.opts.startDelayMs));
}
if (this.opts.failStart) {
this.aliveFlag = false;
throw new Error("fake start failed");
}
}
override async initialize(): Promise<void> {
this.initializeCallCount++;
if (this.opts.initDelayMs !== undefined) {
await new Promise((resolve) => setTimeout(resolve, this.opts.initDelayMs));
}
if (this.opts.failInitialize) {
this.aliveFlag = false;
throw new Error("fake initialize failed");
}
}
override isAlive(): boolean {
return this.aliveFlag;
}
override command(): string[] {
return ["fake-server"];
}
override async stop(): Promise<void> {
this.stopCallCount++;
if (this.opts.stopDelayMs !== undefined) {
await new Promise((resolve) => setTimeout(resolve, this.opts.stopDelayMs));
}
this.aliveFlag = false;
}
}
export function makeServer(id: string, extensions: string[] = [".ts"]): ResolvedServer {
return {
id,
command: ["fake-server", "--stdio"],
extensions,
priority: 0,
};
}
@@ -1,69 +0,0 @@
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { JsonRpcConnection } from "../src/lsp/json-rpc-connection.js";
function encodeMessage(message: Record<string, unknown>): string {
const body = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
}
function readOneMessage(stream: PassThrough): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
stream.once("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
const bodyStart = text.indexOf("\r\n\r\n") + 4;
resolve(JSON.parse(text.slice(bodyStart)) as Record<string, unknown>);
});
});
}
describe("JsonRpcConnection", () => {
it("#given a framed response #when sending request #then resolves the matching result", async () => {
// given
const serverOutput = new PassThrough();
const serverInput = new PassThrough();
const connection = new JsonRpcConnection(serverOutput, serverInput);
connection.listen();
const requestMessage = readOneMessage(serverInput);
// when
const resultPromise = connection.sendRequest<{ capabilities: Record<string, unknown> }>("initialize", {
rootUri: "file:///tmp/project",
});
const request = await requestMessage;
serverOutput.write(encodeMessage({ jsonrpc: "2.0", id: request["id"], result: { capabilities: {} } }));
// then
await expect(resultPromise).resolves.toEqual({ capabilities: {} });
connection.dispose();
});
it("#given a server request #when handler returns #then writes a json-rpc response", async () => {
// given
const serverOutput = new PassThrough();
const serverInput = new PassThrough();
const connection = new JsonRpcConnection(serverOutput, serverInput);
connection.onRequest("workspace/configuration", () => [{ validate: { enable: true } }]);
connection.listen();
// when
const responseMessage = readOneMessage(serverInput);
serverOutput.write(
encodeMessage({
jsonrpc: "2.0",
id: 7,
method: "workspace/configuration",
params: { items: [{ section: "json" }] },
}),
);
// then
await expect(responseMessage).resolves.toMatchObject({
jsonrpc: "2.0",
id: 7,
result: [{ validate: { enable: true } }],
});
connection.dispose();
});
});
@@ -1,126 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LspClient } from "../src/lsp/client.js";
import { LspManager } from "../src/lsp/manager.js";
import type { ResolvedServer } from "../src/lsp/types.js";
import { FakeLspClient, type FakeLspClientOptions, makeServer } from "./helpers/fake-lsp-client.js";
interface FakeContext {
manager: LspManager;
clients: FakeLspClient[];
now: { value: number };
}
function setupManager(options?: {
idleTimeoutMs?: number;
initTimeoutMs?: number;
reaperIntervalMs?: number;
clientFactoryOptions?: () => FakeLspClientOptions;
}): FakeContext {
const clients: FakeLspClient[] = [];
const now = { value: 1_000 };
const manager = new LspManager({
idleTimeoutMs: options?.idleTimeoutMs ?? 5_000,
initTimeoutMs: options?.initTimeoutMs ?? 1_000,
reaperIntervalMs: options?.reaperIntervalMs ?? 100,
now: () => now.value,
clientFactory: (root: string, server: ResolvedServer): LspClient => {
const client = new FakeLspClient(root, server, options?.clientFactoryOptions?.());
clients.push(client);
return client;
},
});
return { manager, clients, now };
}
type ProcessSignalListener = (...args: never[]) => unknown;
function findAddedListener(
signal: NodeJS.Signals,
before: readonly ProcessSignalListener[],
): ProcessSignalListener | undefined {
return process.listeners(signal).find((listener) => !before.includes(listener));
}
describe("LspManager", () => {
it("#given failed start #when later getClient #then failed client is stopped and a fresh client is built", async () => {
// given
let firstCall = true;
const failingFactory = () => {
if (firstCall) {
firstCall = false;
return { failStart: true };
}
return {};
};
const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory });
const server = makeServer("typescript");
// when
await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake start failed");
// then
expect(manager.getSnapshot()).toEqual([]);
expect(clients[0]?.stopCallCount).toBeGreaterThan(0);
const fresh = await manager.getClient("/root/a", server);
expect(clients.length).toBe(2);
expect(fresh).toBe(clients[1]);
await manager.stopAll();
});
it("#given failed initialize #when later getClient #then failed client is stopped and a fresh client is built", async () => {
// given
let firstCall = true;
const failingFactory = () => {
if (firstCall) {
firstCall = false;
return { failInitialize: true };
}
return {};
};
const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory });
const server = makeServer("typescript");
// when
await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake initialize failed");
// then
expect(manager.getSnapshot()).toEqual([]);
expect(clients[0]?.stopCallCount).toBeGreaterThan(0);
const fresh = await manager.getClient("/root/a", server);
expect(clients.length).toBe(2);
expect(fresh).toBe(clients[1]);
await manager.stopAll();
});
it("#given active client #when signal cleanup runs #then client is stopped and handlers unregister", async () => {
// given
const beforeSigterm = process.listeners("SIGTERM");
const { manager, clients } = setupManager();
const server = makeServer("typescript");
try {
await manager.getClient("/root/a", server);
manager.releaseClient("/root/a", server.id);
// when
const listener = findAddedListener("SIGTERM", beforeSigterm);
// then
expect(listener).toBeDefined();
listener?.();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clients[0]?.stopCallCount).toBe(1);
expect(manager.clientCount()).toBe(0);
expect(process.listeners("SIGTERM")).toEqual(beforeSigterm);
} finally {
await manager.stopAll();
}
});
});
@@ -1,103 +0,0 @@
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { handleLspMcpRequest, runMcpStdioServer } from "../src/mcp.js";
describe("lsp MCP server", () => {
it("responds to initialize with tool capabilities", async () => {
const response = await handleLspMcpRequest({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "0.0.0" },
},
});
expect(response).toMatchObject({
jsonrpc: "2.0",
id: 1,
result: {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "lsp", version: "0.1.0" },
},
});
});
it("lists LSP MCP tools", async () => {
const response = await handleLspMcpRequest({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
});
const tools = response?.result?.tools as Array<{ name: string }>;
expect(tools.map((tool) => tool.name)).toEqual([
"status",
"diagnostics",
"goto_definition",
"find_references",
"symbols",
"prepare_rename",
"rename",
]);
});
it("calls status without starting a language server", async () => {
const response = await handleLspMcpRequest({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "status", arguments: {} },
});
expect(response).toMatchObject({
jsonrpc: "2.0",
id: 3,
result: {
isError: false,
},
});
expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers");
});
it("accepts legacy lsp-prefixed tool names without listing them", async () => {
const response = await handleLspMcpRequest({
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "lsp_status", arguments: {} },
});
expect(response).toMatchObject({
jsonrpc: "2.0",
id: 4,
result: {
isError: false,
},
});
expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers");
});
it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => {
// given
const input = new PassThrough();
const output = new PassThrough();
let idleCallCount = 0;
// when
await runMcpStdioServer(input, output, {
idleTimeoutMs: 1,
onIdleTimeout: () => {
idleCallCount++;
input.end();
},
});
// then
expect(idleCallCount).toBe(1);
});
});
@@ -1,63 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly name: string;
readonly license: string;
readonly bin: Record<string, string>;
readonly files: readonly string[];
readonly dependencies?: Record<string, unknown>;
};
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;
}
describe("package metadata", () => {
it("#given packaged files #when validating entrypoints #then package metadata is consistent", () => {
// given
const packageJson = readPackageJson("package.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.name).toBe("@code-yeongyu/lsp-tools-mcp");
expect(packageJson.license).toBe("MIT");
expect(packageJson.dependencies ?? {}).toEqual({});
expect(packageJson.bin["lsp-tools-mcp"]).toBe("./dist/cli.js");
expect(packageJson.files).toEqual(["dist", "LICENSE", "NOTICE", "README.md", "CHANGELOG.md"]);
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(cliSource).toContain("Usage: lsp-tools-mcp [mcp]");
});
});
function isPackageJson(value: unknown): value is PackageJson {
const dependencies = isRecord(value) ? value["dependencies"] : undefined;
return (
isRecord(value) &&
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
value["name"] === "@code-yeongyu/lsp-tools-mcp" &&
value["license"] === "MIT" &&
isStringRecord(value["bin"]) &&
isStringArray(value["files"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isStringArray(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1,151 +0,0 @@
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 { createSpawnCommand, spawnProcess } from "../src/lsp/process.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function readFirstLine(stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = "";
const cleanup = () => {
stream.off("data", onData);
stream.off("error", onError);
};
const onData = (chunk: Buffer | string) => {
buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
cleanup();
resolve(buffer.slice(0, newlineIndex).trim());
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
stream.on("data", onData);
stream.on("error", onError);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function killPidBestEffort(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch {
// Already exited.
}
}
describe("createSpawnCommand", () => {
it("#given windows executable command #when building spawn command #then it avoids shell mode", () => {
// given
const command = ["typescript-language-server", "--stdio"];
// when
const prepared = createSpawnCommand(command, "win32", "cmd.exe");
// then
expect(prepared).toEqual({
command: "typescript-language-server",
args: ["--stdio"],
shell: false,
});
});
it("#given windows cmd shim #when building spawn command #then it uses cmd only for the shim", () => {
// given
const command = ["typescript-language-server.cmd", "--stdio"];
// when
const prepared = createSpawnCommand(command, "win32", "cmd.exe");
// then
expect(prepared).toEqual({
command: "cmd.exe",
args: ["/d", "/s", "/c", "typescript-language-server.cmd", "--stdio"],
shell: false,
});
});
it("#given windows PATH shim #when resolving spawn command #then it executes the shim without shell mode", () => {
// given
const binaryDirectory = mkdtempSync(join(tmpdir(), "codex-lsp-bin-"));
tempDirectories.push(binaryDirectory);
mkdirSync(binaryDirectory, { recursive: true });
const shimPath = join(binaryDirectory, "typescript-language-server.cmd");
writeFileSync(shimPath, "@echo off\n");
// when
const prepared = createSpawnCommand(["typescript-language-server", "--stdio"], "win32", "cmd.exe", {
PATH: binaryDirectory,
PATHEXT: ".cmd;.exe",
});
// then
expect(prepared).toEqual({
command: "cmd.exe",
args: ["/d", "/s", "/c", shimPath, "--stdio"],
shell: false,
});
});
});
describe("spawnProcess", () => {
it.skipIf(process.platform === "win32")(
"#given child process tree #when killing spawned wrapper #then descendant process exits too",
async () => {
// given
const directory = mkdtempSync(join(tmpdir(), "lsp-tools-process-tree-"));
tempDirectories.push(directory);
const script = [
"const { spawn } = require('node:child_process')",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })",
"console.error(String(child.pid))",
"process.on('SIGTERM', () => process.exit(0))",
"setInterval(() => {}, 1000)",
].join(";");
const proc = spawnProcess([process.execPath, "-e", script], { cwd: directory, env: process.env });
const childPid = Number(await readFirstLine(proc.stderr));
try {
// when
proc.kill("SIGTERM");
await Promise.race([proc.exited, sleep(2_000)]);
await sleep(200);
// then
expect(Number.isInteger(childPid)).toBe(true);
expect(isPidAlive(childPid)).toBe(false);
} finally {
killPidBestEffort(childPid);
proc.kill("SIGKILL");
}
},
);
});
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { AUTO_INSTALLABLE_SERVERS, BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "../src/lsp/server-definitions.js";
describe("BUILTIN_SERVERS", () => {
it("#given rust #when looking it up #then maps to rust-analyzer", () => {
// given
const rust = BUILTIN_SERVERS["rust"];
// when / then
expect(rust).toBeDefined();
expect(rust?.command[0]).toBe("rust-analyzer");
expect(rust?.extensions).toEqual([".rs"]);
});
it("#given rust install guidance #when inspecting registry #then rust is manual install only", () => {
// given
const hint = LSP_INSTALL_HINTS["rust"];
// when / then
expect(AUTO_INSTALLABLE_SERVERS["rust"]).toBeUndefined();
expect(hint).toContain("rust-analyzer");
expect(hint).toContain("rustup component add rust-analyzer");
expect(hint).toContain("rustup component remove rust-src");
expect(hint).toContain("rustup component add rust-src");
});
});
@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import { LspProcessExitedError } from "../src/lsp/errors.js";
import { formatKnownLspStartupFailure, handleMissingDependencyError } from "../src/lsp/utils.js";
describe("formatKnownLspStartupFailure", () => {
it("#given rust-src component conflict #when formatting startup failure #then returns repair guidance", () => {
// given
const error = new LspProcessExitedError(
"rust",
"/repo",
1,
"failed to install component: 'rust-src', detected conflict: 'lib/rustlib/src/rust/library/Cargo.lock'",
);
// when
const message = formatKnownLspStartupFailure(error);
// then
expect(message).toContain("rust-analyzer");
expect(message).toContain("rustup component remove rust-src");
expect(message).toContain("rustup component add rust-src");
expect(message).toContain("detected conflict");
expect(message).toContain("Cargo.lock");
expect(message).not.toContain("automatic repair");
});
it("#given rust-analyzer sysroot error #when handling missing dependency #then returns repair guidance", () => {
// given
const error = new LspProcessExitedError(
"rust",
"/repo",
1,
"can't load standard library from sysroot\ntry installing `rust-src` the same way you installed `rustc`",
);
// when
const message = handleMissingDependencyError(error);
// then
expect(message).toContain("rustup component remove rust-src");
expect(message).toContain("rustup component add rust-src");
expect(message).toContain("can't load standard library");
});
it("#given unrelated process exits #when formatting startup failure #then returns null", () => {
// given
const typescriptError = new LspProcessExitedError(
"typescript",
"/repo",
1,
"failed to install component: 'rust-src', detected conflict",
);
const rustPanic = new LspProcessExitedError("rust", "/repo", 1, "thread panicked while loading crate graph");
// when / then
expect(formatKnownLspStartupFailure(typescriptError)).toBeNull();
expect(formatKnownLspStartupFailure(rustPanic)).toBeNull();
});
});
describe("handleMissingDependencyError", () => {
it("#given existing dependency messages #when handling error #then preserves current messages", () => {
// given
const notInstalled = new Error("LSP server 'typescript' is configured but NOT INSTALLED.");
const notConfigured = new Error("No LSP server configured for extension: .md");
// when / then
expect(handleMissingDependencyError(notInstalled)).toBe(notInstalled.message);
expect(handleMissingDependencyError(notConfigured)).toBe(notConfigured.message);
});
});
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -1,27 +0,0 @@
{
"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/**/*"]
}
@@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
pool: "threads",
},
});
@@ -1,38 +0,0 @@
#!/usr/bin/env node
// Bootstrap the lsp-tools-mcp git submodule for local development.
// CI runs the install+build steps explicitly in the workflow, so this
// script is mostly for `npm run bootstrap` after a fresh clone and as a
// chained pre-step before typecheck / test / check so contributors do not
// have to remember it.
//
// Idempotent: skips when dist/cli.js already exists, unless --force is passed.
import { existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const submoduleDir = join(__dirname, "..", "packages", "lsp-tools-mcp");
const submodulePackageJson = join(submoduleDir, "package.json");
const submoduleDistCli = join(submoduleDir, "dist", "cli.js");
const force = process.argv.includes("--force");
if (!existsSync(submodulePackageJson)) {
console.error(
"lsp-tools-mcp submodule is missing. Run: git submodule update --init --recursive",
);
process.exit(1);
}
if (!force && existsSync(submoduleDistCli)) {
// Already built; nothing to do.
process.exit(0);
}
console.log("Installing lsp-tools-mcp dependencies...");
execSync("npm ci", { cwd: submoduleDir, stdio: "inherit" });
console.log("Building lsp-tools-mcp...");
execSync("npm run build", { cwd: submoduleDir, stdio: "inherit" });
console.log("Done.");
@@ -0,0 +1,31 @@
#!/usr/bin/env node
// Build the repository-level lsp-tools-mcp package used by codex-lsp.
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const lspToolsDir = join(__dirname, "..", "..", "..", "..", "..", "lsp-tools-mcp");
const packageJson = join(lspToolsDir, "package.json");
const distCli = join(lspToolsDir, "dist", "cli.js");
const force = process.argv.includes("--force");
if (!force && existsSync(distCli)) {
process.exit(0);
}
if (!existsSync(packageJson)) {
console.error(
`lsp-tools-mcp package metadata is missing at ${packageJson}; build packages/lsp-tools-mcp before codex-lsp`,
);
process.exit(1);
}
console.log("Installing repository lsp-tools-mcp dependencies...");
execSync("npm ci", { cwd: lspToolsDir, stdio: "inherit" });
console.log("Building repository lsp-tools-mcp...");
execSync("npm run build", { cwd: lspToolsDir, stdio: "inherit" });
console.log("Done.");
@@ -9,12 +9,6 @@ type PackageJson = {
readonly dependencies: Record<string, string>;
};
type PluginJson = {
readonly version: string;
readonly hooks: string;
readonly mcpServers: string;
};
type HookCommand = {
readonly command: string;
};
@@ -42,12 +36,6 @@ function readPackageJson(path: string): PackageJson {
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}`);
@@ -61,10 +49,9 @@ function readMcpJson(path: string): McpJson {
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
it("#given packaged component files #when validating entrypoints #then hook and MCP commands use root LSP tooling", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const mcpJson = readMcpJson(".mcp.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
@@ -75,19 +62,16 @@ describe("plugin package metadata", () => {
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
// then
expect(pluginJson.version).toBe(packageJson.version);
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies).toEqual({
"@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp",
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp",
});
expect(packageJson.bin["codex-lsp"]).toBe("./dist/cli.js");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(pluginJson.mcpServers).toBe("./.mcp.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
expect(lspServer?.command).toBe("node");
expect(lspServer?.args).toEqual(["./packages/lsp-tools-mcp/dist/cli.js", "mcp"]);
expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]);
});
it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => {
@@ -115,15 +99,6 @@ function isPackageJson(value: unknown): value is PackageJson {
);
}
function isPluginJson(value: unknown): value is PluginJson {
return (
isRecord(value) &&
typeof value["version"] === "string" &&
typeof value["hooks"] === "string" &&
typeof value["mcpServers"] === "string"
);
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
import { linkCachedPluginBins } from "./install/cache.mjs";
import { makeTempDir, writeJson } from "./install-test-fixtures.mjs";
test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => {
const root = await makeTempDir();
const pluginRoot = join(root, "plugin");
const binDir = join(root, "bin");
await mkdir(pluginRoot, { recursive: true });
await writeJson(join(pluginRoot, "package.json"), {
name: "@example/alpha",
bin: {
alpha: "./dist/cli.js",
},
});
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" });
assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]);
const shim = await readFile(join(binDir, "alpha.cmd"), "utf8");
assert.match(shim, /@echo off/);
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
});
test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => {
const root = await makeTempDir();
const pluginRoot = join(root, "plugin");
const binDir = join(root, "bin");
await mkdir(pluginRoot, { recursive: true });
await mkdir(binDir, { recursive: true });
await writeJson(join(pluginRoot, "package.json"), {
name: "@example/alpha",
bin: {
alpha: "./dist/cli.js",
},
});
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n");
await assert.rejects(
linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }),
/already exists and is not a generated command shim/,
);
assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/);
});
+21 -4
View File
@@ -3,7 +3,12 @@ import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./install/cache.mjs";
import {
installCachedPlugin,
linkCachedPluginBins,
pruneMarketplaceCache,
pruneMarketplacePluginCaches,
} from "./install/cache.mjs";
import { updateCodexConfig } from "./install/config.mjs";
import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs";
import { defaultRunCommand } from "./install/process.mjs";
@@ -14,6 +19,8 @@ import {
validatePathSegment,
} from "./install/marketplace.mjs";
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"];
export async function installMarketplaceLocally(options = {}) {
const repoRoot = resolve(options.repoRoot ?? process.cwd());
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
@@ -21,11 +28,14 @@ export async function installMarketplaceLocally(options = {}) {
const platform = options.platform ?? process.platform;
const runCommand = options.runCommand ?? defaultRunCommand;
const log = options.log ?? console.log;
const marketplace = await readMarketplace(repoRoot);
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
const marketplace = await readMarketplace(repoRoot, {
marketplacePath: join(codexPackageRoot, "marketplace.json"),
});
const installed = [];
for (const entry of marketplace.plugins) {
const sourcePath = resolvePluginSource(repoRoot, entry);
const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" });
const manifest = await readPluginManifest(sourcePath);
if (manifest.name !== entry.name) {
throw new Error(
@@ -64,9 +74,12 @@ export async function installMarketplaceLocally(options = {}) {
)
).flat();
await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames });
for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) {
await pruneMarketplacePluginCaches({ codexHome, marketplaceName: legacyMarketplaceName, pluginNames });
}
await updateCodexConfig({
configPath: join(codexHome, "config.toml"),
repoRoot,
repoRoot: codexPackageRoot,
marketplaceName: marketplace.name,
pluginNames,
trustedHookStates,
@@ -79,6 +92,10 @@ export async function installMarketplaceLocally(options = {}) {
return { marketplaceName: marketplace.name, installed };
}
function legacyCacheMarketplaces(marketplaceName) {
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : [];
}
async function main() {
const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
const result = await installMarketplaceLocally({ repoRoot });
@@ -4,35 +4,62 @@ import { join } from "node:path";
import test from "node:test";
import { installMarketplaceLocally } from "./install-local.mjs";
import { linkCachedPluginBins } from "./install/cache.mjs";
import { makeTempDir, writeJson, writePlugin } from "./install-test-fixtures.mjs";
import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs";
test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
const binDir = await makeTempDir();
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
const pluginRoot = join(codexPackageRoot, "plugin");
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
await writeJson(join(codexPackageRoot, "marketplace.json"), {
name: "debug-marketplace",
plugins: [
{
name: "alpha",
source: "./plugins/alpha",
},
{
name: "beta",
source: {
source: "local",
path: "./plugins/beta",
},
},
],
});
await writePlugin(repoRoot, "alpha", "1.2.3");
await writePlugin(repoRoot, "beta", "0.4.0");
await mkdir(join(repoRoot, "plugins", "alpha", "node_modules"), { recursive: true });
await writeFile(join(repoRoot, "plugins", "alpha", "node_modules", "skip.txt"), "skip");
await writePluginAt(pluginRoot, "alpha", "1.2.3");
await mkdir(join(codexPackageRoot, "shared-lsp", "dist"), { recursive: true });
await writeJson(join(codexPackageRoot, "shared-lsp", "package.json"), {
name: "@example/shared-lsp",
version: "0.0.0",
type: "module",
bin: { "shared-lsp": "./dist/cli.js" },
});
await writeFile(join(codexPackageRoot, "shared-lsp", "dist", "cli.js"), "#!/usr/bin/env node\n");
await writeJson(join(pluginRoot, "package.json"), {
name: "@example/alpha",
version: "1.2.3",
bin: {
alpha: "./dist/cli.js",
},
scripts: {
build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"",
},
dependencies: {
"@example/shared-lsp": "file:../shared-lsp",
},
});
await writeJson(join(pluginRoot, ".mcp.json"), {
mcpServers: {
alpha: {
command: "node",
args: ["./dist/cli.js", "mcp"],
cwd: ".",
},
shared: {
command: "node",
args: ["../shared-lsp/dist/cli.js", "mcp"],
cwd: ".",
},
},
});
await mkdir(join(pluginRoot, "node_modules"), { recursive: true });
await writeFile(join(pluginRoot, "node_modules", "skip.txt"), "skip");
await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true });
await writeFile(
join(codexHome, "config.toml"),
@@ -60,19 +87,23 @@ test("#given local marketplace #when installing #then copies versioned plugins a
assert.deepEqual(
result.installed.map((plugin) => `${plugin.name}@${plugin.version}`),
["alpha@1.2.3", "beta@0.4.0"],
["alpha@1.2.3"],
);
const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3");
assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true);
assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js"));
const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8"));
assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]);
assert.deepEqual(alphaMcp.mcpServers.shared.args, [join(codexPackageRoot, "shared-lsp", "dist", "cli.js"), "mcp"]);
assert.equal(
Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"),
false,
"`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd",
);
assert.equal(Object.hasOwn(alphaMcp.mcpServers.shared, "cwd"), false);
assert.equal(alphaMcp.mcpServers.alpha.command, "node");
const alphaPackageJson = JSON.parse(await readFile(join(alphaCacheRoot, "package.json"), "utf8"));
assert.equal(alphaPackageJson.dependencies["@example/shared-lsp"], `file:${join(codexPackageRoot, "shared-lsp")}`);
await assert.rejects(
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")),
/code: 'ENOENT'|ENOENT/,
@@ -84,12 +115,9 @@ test("#given local marketplace #when installing #then copies versioned plugins a
assert.deepEqual(
commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]),
[
["npm", "install", join(repoRoot, "plugins", "alpha")],
["npm", "run build", join(repoRoot, "plugins", "alpha")],
["npm", "install", pluginRoot],
["npm", "run build", pluginRoot],
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")],
["npm", "install", join(repoRoot, "plugins", "beta")],
["npm", "run build", join(repoRoot, "plugins", "beta")],
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "beta", "0.4.0")],
],
);
@@ -98,20 +126,49 @@ test("#given local marketplace #when installing #then copies versioned plugins a
assert.match(config, /\[marketplaces\.debug-marketplace\]/);
assert.match(config, /source_type = "local"/);
assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/);
assert.match(config, /\[plugins\."beta@debug-marketplace"\]\nenabled = true/);
assert.doesNotMatch(config, /stale@debug-marketplace/);
});
test("#given sisyphuslabs marketplace #when installing #then registers lazycodex git source", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
await writeJson(join(codexPackageRoot, "marketplace.json"), {
name: "sisyphuslabs",
plugins: [{ name: "omo", source: "./plugins/omo" }],
});
await writePlugin(repoRoot, "omo", "0.1.0");
await writePluginAt(join(codexPackageRoot, "plugin"), "omo", "0.1.0");
await mkdir(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0"), {
recursive: true,
});
await writeJson(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0", ".mcp.json"), {
mcpServers: {
lsp: {
command: "node",
args: ["old/components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"],
},
},
});
await writeFile(
join(codexHome, "config.toml"),
[
"[marketplaces.code-yeongyu-codex-plugins]",
'last_updated = "2026-05-01T00:00:00Z"',
'source_type = "git"',
'source = "https://github.com/code-yeongyu/codex-plugins.git"',
"",
'[plugins."omo@code-yeongyu-codex-plugins"]',
"enabled = true",
"",
'[plugins."omo@code-yeongyu-codex-plugins".mcp_servers.lsp]',
'enabled = true',
"",
'[hooks.state."omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0"]',
'trusted_hash = "sha256:old"',
"",
].join("\n"),
);
await installMarketplaceLocally({
repoRoot,
@@ -129,19 +186,24 @@ test("#given sisyphuslabs marketplace #when installing #then registers lazycodex
assert.doesNotMatch(config, /\[marketplaces\.lazycodex\]/);
assert.doesNotMatch(config, /code-yeongyu-codex-plugins/);
assert.doesNotMatch(config, /source_type = "local"/);
await assert.rejects(
stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo")),
/code: 'ENOENT'|ENOENT/,
);
});
test("#given plugin hooks #when installing #then records trusted hook hashes", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
await writeJson(join(codexPackageRoot, "marketplace.json"), {
name: "debug-marketplace",
plugins: [{ name: "alpha", source: "./plugins/alpha" }],
});
await writePlugin(repoRoot, "alpha", "1.2.3");
await writeJson(join(repoRoot, "plugins", "alpha", "hooks", "hooks.json"), {
const pluginRoot = join(codexPackageRoot, "plugin");
await writePluginAt(pluginRoot, "alpha", "1.2.3");
await writeJson(join(pluginRoot, "hooks", "hooks.json"), {
hooks: {
UserPromptSubmit: [
{
@@ -173,9 +235,9 @@ test("#given plugin hooks #when installing #then records trusted hook hashes", a
test("#given bad plugin source path #when installing #then rejects traversal", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
await writeJson(join(codexPackageRoot, "marketplace.json"), {
name: "debug-marketplace",
plugins: [
{
@@ -190,50 +252,3 @@ test("#given bad plugin source path #when installing #then rejects traversal", a
/local plugin source path must start with \.\//,
);
});
test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => {
const root = await makeTempDir();
const pluginRoot = join(root, "plugin");
const binDir = join(root, "bin");
await mkdir(pluginRoot, { recursive: true });
await writeJson(join(pluginRoot, "package.json"), {
name: "@example/alpha",
bin: {
alpha: "./dist/cli.js",
},
});
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" });
assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]);
const shim = await readFile(join(binDir, "alpha.cmd"), "utf8");
assert.match(shim, /@echo off/);
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
});
test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => {
const root = await makeTempDir();
const pluginRoot = join(root, "plugin");
const binDir = join(root, "bin");
await mkdir(pluginRoot, { recursive: true });
await mkdir(binDir, { recursive: true });
await writeJson(join(pluginRoot, "package.json"), {
name: "@example/alpha",
bin: {
alpha: "./dist/cli.js",
},
});
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n");
await assert.rejects(
linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }),
/already exists and is not a generated command shim/,
);
assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/);
});
@@ -13,6 +13,10 @@ export async function writeJson(path, value) {
export async function writePlugin(root, name, version) {
const pluginRoot = join(root, "plugins", name);
await writePluginAt(pluginRoot, name, version);
}
export async function writePluginAt(pluginRoot, name, version) {
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await mkdir(join(pluginRoot, "hooks"), { recursive: true });
@@ -28,6 +28,17 @@ export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPl
}
}
export async function pruneMarketplacePluginCaches({ codexHome, marketplaceName, pluginNames }) {
const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
if (!(await exists(cacheRoot))) return;
for (const pluginName of pluginNames) {
await rm(join(cacheRoot, pluginName), { recursive: true, force: true });
}
if ((await readdir(cacheRoot)).length === 0) {
await rm(cacheRoot, { recursive: true, force: true });
}
}
export async function linkCachedPluginBins({ binDir, pluginRoot, platform = process.platform }) {
const binLinks = await discoverPackageBins(pluginRoot);
await mkdir(binDir, { recursive: true });
+35 -3
View File
@@ -8,6 +8,7 @@ const LAZYCODEX_MARKETPLACE_SOURCE = {
source: "https://github.com/code-yeongyu/lazycodex.git",
ref: "main",
};
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"];
export async function updateCodexConfig({
configPath,
@@ -21,6 +22,11 @@ export async function updateCodexConfig({
let config = "";
if (await exists(configPath)) config = await readFile(configPath, "utf8");
for (const legacyMarketplaceName of legacyMarketplaceNames(marketplaceName)) {
config = removeMarketplaceBlock(config, legacyMarketplaceName);
config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set());
config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set());
}
config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames));
config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames));
config = ensureFeatureEnabled(config, "plugins");
@@ -36,6 +42,14 @@ export async function updateCodexConfig({
await writeFile(configPath, config.trimEnd() + "\n");
}
function legacyMarketplaceNames(marketplaceName) {
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : [];
}
function removeMarketplaceBlock(config, marketplaceName) {
return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`);
}
function defaultMarketplaceSource(marketplaceName, repoRoot) {
if (marketplaceName === "sisyphuslabs") return LAZYCODEX_MARKETPLACE_SOURCE;
return {
@@ -46,7 +60,7 @@ function defaultMarketplaceSource(marketplaceName, repoRoot) {
function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) {
return removeTomlSections(config, (header) => {
const pluginKey = parseQuotedPluginHeader(header);
const pluginKey = parsePluginHeaderKey(header);
if (pluginKey === null) return false;
const suffix = `@${marketplaceName}`;
if (!pluginKey.endsWith(suffix)) return false;
@@ -170,10 +184,28 @@ function parseTomlHeader(line) {
return trimmed.slice(1, -1);
}
function parseQuotedPluginHeader(header) {
function parsePluginHeaderKey(header) {
const prefix = "plugins.";
if (!header.startsWith(prefix)) return null;
return parseJsonString(header.slice(prefix.length));
return parseLeadingJsonString(header.slice(prefix.length));
}
function parseLeadingJsonString(value) {
if (!value.startsWith('"')) return parseJsonString(value);
let escaped = false;
for (let index = 1; index < value.length; index += 1) {
const char = value[index];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') return parseJsonString(value.slice(0, index + 1));
}
return null;
}
function parseJsonString(value) {
@@ -3,10 +3,10 @@ import { join } from "node:path";
import { isRecord } from "./utils.mjs";
const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json";
export async function readMarketplace(repoRoot) {
const marketplacePath = join(repoRoot, MARKETPLACE_PATH);
export async function readMarketplace(repoRoot, options = {}) {
const marketplacePath = options.marketplacePath ?? join(repoRoot, DEFAULT_MARKETPLACE_PATH);
const raw = await readFile(marketplacePath, "utf8");
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object");
@@ -22,10 +22,10 @@ export async function readMarketplace(repoRoot) {
};
}
export function resolvePluginSource(repoRoot, plugin) {
const sourcePath = localSourcePath(plugin.source);
export function resolvePluginSource(marketplaceRoot, plugin, options = {}) {
const sourcePath = localSourcePath(options.pathOverride ?? plugin.source);
const relativePath = sourcePath.slice(2);
return join(repoRoot, ...relativePath.split(/[\\/]/));
return join(marketplaceRoot, ...relativePath.split(/[\\/]/));
}
export async function readPluginManifest(pluginRoot) {
@@ -60,10 +60,21 @@ function normalizeMarketplacePlugin(plugin, index) {
throw new Error(`marketplace plugin ${index} name must be a non-empty string`);
}
validatePathSegment(plugin.name, "plugin name");
return {
name: plugin.name,
source: plugin.source,
};
if (plugin.source === undefined || typeof plugin.source === "string") {
if (typeof plugin.source === "string") validateLocalSourcePath(plugin.source);
return {
name: plugin.name,
source: plugin.source,
};
}
if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") {
validateLocalSourcePath(plugin.source.path);
return {
name: plugin.name,
source: { source: "local", path: plugin.source.path },
};
}
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
}
function localSourcePath(source) {
+15
View File
@@ -42,6 +42,21 @@ export async function pruneMarketplaceCache(input: {
}
}
export async function pruneMarketplacePluginCaches(input: {
readonly codexHome: string
readonly marketplaceName: string
readonly pluginNames: readonly string[]
}): Promise<void> {
const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName)
if (!(await exists(cacheRoot))) return
for (const pluginName of input.pluginNames) {
await rm(join(cacheRoot, pluginName), { recursive: true, force: true })
}
if ((await readdir(cacheRoot)).length === 0) {
await rm(cacheRoot, { recursive: true, force: true })
}
}
export async function linkCachedPluginBins(input: {
readonly binDir: string
readonly pluginRoot: string
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile } from "node:fs/promises"
import { mkdtemp, readFile, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { updateCodexConfig } from "./codex-config-toml"
@@ -9,6 +9,25 @@ describe("codex-config-toml", () => {
// given
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-"))
const configPath = join(root, "config.toml")
await writeFile(
configPath,
[
"[marketplaces.code-yeongyu-codex-plugins]",
'last_updated = "2026-05-01T00:00:00Z"',
'source_type = "git"',
'source = "https://github.com/code-yeongyu/codex-plugins.git"',
"",
'[plugins."omo@code-yeongyu-codex-plugins"]',
"enabled = true",
"",
'[plugins."omo@code-yeongyu-codex-plugins".mcp_servers.lsp]',
"enabled = true",
"",
'[hooks.state."omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0"]',
'trusted_hash = "sha256:old"',
"",
].join("\n"),
)
// when
await updateCodexConfig({
+36 -3
View File
@@ -2,6 +2,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
import type { CodexMarketplaceSource, TrustedHookState } from "./types"
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
export async function updateCodexConfig(input: {
readonly configPath: string
readonly repoRoot: string
@@ -15,6 +17,11 @@ export async function updateCodexConfig(input: {
if (await exists(input.configPath)) config = await readFile(input.configPath, "utf8")
const pluginSet = new Set(input.pluginNames)
for (const legacyMarketplaceName of legacyMarketplaceNames(input.marketplaceName)) {
config = removeMarketplaceBlock(config, legacyMarketplaceName)
config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set())
config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set())
}
config = removeStaleMarketplacePluginBlocks(config, input.marketplaceName, pluginSet)
config = removeStaleMarketplaceHookStateBlocks(config, input.marketplaceName, pluginSet)
config = ensureFeatureEnabled(config, "plugins")
@@ -30,9 +37,17 @@ export async function updateCodexConfig(input: {
await writeFile(input.configPath, `${config.trimEnd()}\n`)
}
function legacyMarketplaceNames(marketplaceName: string): readonly string[] {
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : []
}
function removeMarketplaceBlock(config: string, marketplaceName: string): string {
return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`)
}
function removeStaleMarketplacePluginBlocks(config: string, marketplaceName: string, keepPluginNames: Set<string>): string {
return removeTomlSections(config, (header) => {
const pluginKey = parseQuotedPluginHeader(header)
const pluginKey = parsePluginHeaderKey(header)
if (pluginKey === null) return false
const suffix = `@${marketplaceName}`
if (!pluginKey.endsWith(suffix)) return false
@@ -158,10 +173,28 @@ function parseTomlHeader(line: string): string | null {
return trimmed.slice(1, -1)
}
function parseQuotedPluginHeader(header: string): string | null {
function parsePluginHeaderKey(header: string): string | null {
const prefix = "plugins."
if (!header.startsWith(prefix)) return null
return parseJsonString(header.slice(prefix.length))
return parseLeadingJsonString(header.slice(prefix.length))
}
function parseLeadingJsonString(value: string): string | null {
if (!value.startsWith('"')) return parseJsonString(value)
let escaped = false
for (let index = 1; index < value.length; index += 1) {
const char = value[index]
if (escaped) {
escaped = false
continue
}
if (char === "\\") {
escaped = true
continue
}
if (char === '"') return parseJsonString(value.slice(0, index + 1))
}
return null
}
function parseJsonString(value: string): string | null {
+5 -1
View File
@@ -2,7 +2,7 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile, stat } from "node:fs/promises"
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { runCodexInstaller } from "./install-codex"
@@ -13,6 +13,9 @@ describe("install-codex", () => {
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-"))
const repoRoot = process.cwd()
const legacyCacheRoot = join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0")
await mkdir(legacyCacheRoot, { recursive: true })
await writeFile(join(legacyCacheRoot, ".mcp.json"), JSON.stringify({ mcpServers: { lsp: { args: ["old-lsp"] } } }))
// when
const first = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined })
@@ -37,5 +40,6 @@ describe("install-codex", () => {
expect(pluginPath).toContain(join("plugins", "cache", "sisyphuslabs", "omo"))
const stats = await stat(pluginPath ?? "")
expect(stats.isDirectory()).toBe(true)
await expect(stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo"))).rejects.toThrow()
})
})
+13 -1
View File
@@ -1,7 +1,7 @@
import { homedir } from "node:os"
import { join, resolve } from "node:path"
import { existsSync } from "node:fs"
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache"
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
import { updateCodexConfig } from "./codex-config-toml"
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
@@ -14,6 +14,7 @@ const LAZYCODEX_MARKETPLACE_SOURCE = {
source: "https://github.com/code-yeongyu/lazycodex.git",
ref: "main",
} as const
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise<CodexInstallResult> {
const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir))
@@ -78,6 +79,13 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
marketplaceName: marketplace.name,
keepPluginNames: marketplace.plugins.map((plugin) => plugin.name),
})
for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) {
await pruneMarketplacePluginCaches({
codexHome,
marketplaceName: legacyMarketplaceName,
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
})
}
const configPath = join(codexHome, "config.toml")
await updateCodexConfig({
@@ -99,6 +107,10 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
}
}
function legacyCacheMarketplaces(marketplaceName: string): readonly string[] {
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []
}
function findRepoRootFromImporter(importerDir: string): string {
let current = importerDir
for (let depth = 0; depth <= 5; depth += 1) {