diff --git a/package.json b/package.json index d23167e74..b7e191e4a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/lsp-tools-mcp b/packages/lsp-tools-mcp index e3cae69bd..83e634172 160000 --- a/packages/lsp-tools-mcp +++ b/packages/lsp-tools-mcp @@ -1 +1 @@ -Subproject commit e3cae69bd68357c85f65339194f8028934ee879c +Subproject commit 83e634172ebc79662c6e29d8b3ae89f927b10364 diff --git a/packages/omo-codex/plugin/components/lsp/.gitmodules b/packages/omo-codex/plugin/components/lsp/.gitmodules deleted file mode 100644 index f172b519f..000000000 --- a/packages/omo-codex/plugin/components/lsp/.gitmodules +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/.mcp.json b/packages/omo-codex/plugin/components/lsp/.mcp.json index 13ff674da..46ecc491f 100644 --- a/packages/omo-codex/plugin/components/lsp/.mcp.json +++ b/packages/omo-codex/plugin/components/lsp/.mcp.json @@ -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": "." } } diff --git a/packages/omo-codex/plugin/components/lsp/CHANGELOG.md b/packages/omo-codex/plugin/components/lsp/CHANGELOG.md index 5853ed1fb..319414453 100644 --- a/packages/omo-codex/plugin/components/lsp/CHANGELOG.md +++ b/packages/omo-codex/plugin/components/lsp/CHANGELOG.md @@ -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). diff --git a/packages/omo-codex/plugin/components/lsp/README.md b/packages/omo-codex/plugin/components/lsp/README.md index a449ef993..90acc6545 100644 --- a/packages/omo-codex/plugin/components/lsp/README.md +++ b/packages/omo-codex/plugin/components/lsp/README.md @@ -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: diff --git a/packages/omo-codex/plugin/components/lsp/package.json b/packages/omo-codex/plugin/components/lsp/package.json index 3a45e161e..b61ad07cc 100644 --- a/packages/omo-codex/plugin/components/lsp/package.json +++ b/packages/omo-codex/plugin/components/lsp/package.json @@ -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", diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitattributes b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitattributes deleted file mode 100644 index 9363fdb74..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitattributes +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS deleted file mode 100644 index e2330ddf8..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @code-yeongyu diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml deleted file mode 100644 index 206a5f9d2..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml deleted file mode 100644 index 4f0042f6a..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json deleted file mode 100644 index 1f482bb66..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json +++ /dev/null @@ -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" - } - ] -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml deleted file mode 100644 index be719fac4..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md deleted file mode 100644 index 39f36ad46..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md +++ /dev/null @@ -1,11 +0,0 @@ -## Summary - -- - -## Validation - -- - -## Notes - -- diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml deleted file mode 100644 index 6cc7a1653..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml +++ /dev/null @@ -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 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml deleted file mode 100644 index 4214a99b2..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml +++ /dev/null @@ -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 }} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore deleted file mode 100644 index 1bf09f16c..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -*.log -.env -.DS_Store -coverage/ -.vitest/ diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md deleted file mode 100644 index 4bff36af9..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md +++ /dev/null @@ -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) diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE deleted file mode 100644 index 09aac3c3b..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE +++ /dev/null @@ -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. diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE deleted file mode 100644 index a3adc175d..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE +++ /dev/null @@ -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. diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md deleted file mode 100644 index 906b7c614..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md +++ /dev/null @@ -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) diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/biome.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/biome.json deleted file mode 100644 index 5aa1a0dcc..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/biome.json +++ /dev/null @@ -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" - } - } - } - } - ] -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/package.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/package.json deleted file mode 100644 index 52a344d02..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts deleted file mode 100644 index c0dc203d4..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts +++ /dev/null @@ -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 { - 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; -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts deleted file mode 100644 index 30e7018bf..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts +++ /dev/null @@ -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}`); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts deleted file mode 100644 index 9e9d4bc85..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts +++ /dev/null @@ -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): 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( - filePath: string, - fn: (client: LspClient) => Promise, - toolName: string, - options: WithLspClientOptions = {}, -): Promise { - 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 => { - 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); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts deleted file mode 100644 index 45dcf86fc..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts +++ /dev/null @@ -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(); - private readonly documentVersions = new Map(); - private readonly lastSyncedText = new Map(); - private readonly diagnosticPullErrors: Error[] = []; - - getDiagnosticPullErrors(): readonly Error[] { - return this.diagnosticPullErrors; - } - - async openFile(filePath: string): Promise { - 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 | null> { - const absPath = resolve(filePath); - await this.openFile(absPath); - return this.sendRequest | null>( - "textDocument/definition", - { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }, - ); - } - - async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { - const absPath = resolve(filePath); - await this.openFile(absPath); - return this.sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - context: { includeDeclaration }, - }); - } - - async documentSymbols(filePath: string): Promise> { - const absPath = resolve(filePath); - await this.openFile(absPath); - return this.sendRequest>("textDocument/documentSymbol", { - textDocument: { uri: pathToFileURL(absPath).href }, - }); - } - - async workspaceSymbols(query: string): Promise { - return this.sendRequest("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 { - const absPath = resolve(filePath); - await this.openFile(absPath); - return this.sendRequest( - "textDocument/prepareRename", - { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }, - ); - } - - async rename(filePath: string, line: number, character: number, newName: string): Promise { - const absPath = resolve(filePath); - await this.openFile(absPath); - return this.sendRequest("textDocument/rename", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - newName, - }); - } -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts deleted file mode 100644 index 245938b9f..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts +++ /dev/null @@ -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; - initialization?: Record; -} - -interface ConfigJson { - lsp?: Record; -} - -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 { - const paths = getConfigPaths(); - const configs = new Map(); - - 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(); - const seen = new Set(); - - 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 { - return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function getDisabledServerIds(): Set { - const configs = loadAllConfigs(); - const disabled = new Set(); - - 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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts deleted file mode 100644 index 8cf905fce..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts +++ /dev/null @@ -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 { - 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)); - } -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts deleted file mode 100644 index 8dbddbbf5..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts +++ /dev/null @@ -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; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts deleted file mode 100644 index 90e6849a8..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts +++ /dev/null @@ -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 { - 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"); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts deleted file mode 100644 index 186c3c716..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts +++ /dev/null @@ -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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts deleted file mode 100644 index 7f8e836a6..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts +++ /dev/null @@ -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; - -const DIAGNOSTIC_SEVERITY_FILTERS = { - error: 1, - warning: 2, - information: 3, - hint: 4, -} as const satisfies Readonly>; - -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"); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts deleted file mode 100644 index 31771cbcc..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts +++ /dev/null @@ -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(); - 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 | 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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts deleted file mode 100644 index 2769466af..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts +++ /dev/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; - -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(); - private readonly notificationHandlers = new Map(); - private readonly requestHandlers = new Map(); - 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(method: string, params?: unknown): Promise { - 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((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 { - 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): 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): 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 { - await this.writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); - } - - private writeMessage(message: Record): Promise { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function getMessageId(message: Record): 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)); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts deleted file mode 100644 index ace3c2345..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts +++ /dev/null @@ -1,172 +0,0 @@ -export const SYMBOL_KIND_MAP: Record = { - 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 = { - 1: "error", - 2: "warning", - 3: "information", - 4: "hint", -}; - -export const EXT_TO_LANG: Record = { - ".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"; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts deleted file mode 100644 index 6b2e95a54..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts +++ /dev/null @@ -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 | 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 { - try { - await client.stop(); - } catch (error) { - reportBestEffortCleanupError("client stop", error); - } -} - -function awaitWithSignal(promise: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) return promise; - return new Promise((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(); - 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 { - 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 { - 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 { - this.disposed = true; - - if (this.reaperHandle) { - clearInterval(this.reaperHandle); - this.reaperHandle = null; - } - - if (this.signalDisposer) { - this.signalDisposer(); - this.signalDisposer = null; - } - - const stopPromises: Promise[] = []; - 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 { - if (_defaultInstance) { - const m = _defaultInstance; - _defaultInstance = null; - await m.stopAll(); - } -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts deleted file mode 100644 index 9ab3a4ee7..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { reportBestEffortCleanupError } from "./cleanup-errors.js"; - -export function installProcessSignalCleanup(cleanup: () => Promise): () => 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); - } - }; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts deleted file mode 100644 index e059b1411..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts +++ /dev/null @@ -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; - kill(signal?: NodeJS.Signals): void; - killed: boolean; -} - -export interface SpawnOptions { - cwd: string; - env: Record; -} - -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((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[] { - 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 { - 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 = 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); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts deleted file mode 100644 index b8e728640..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { LspServerConfig } from "./types.js"; - -export const LSP_INSTALL_HINTS: Record = { - 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> = { - 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 = { - 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"], -}; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts deleted file mode 100644 index 622a174d0..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts +++ /dev/null @@ -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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts deleted file mode 100644 index e08478c99..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts +++ /dev/null @@ -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(); - - 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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts deleted file mode 100644 index 49ecf7ead..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts +++ /dev/null @@ -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 { - 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(); - - 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 { - const env: Record = { - ...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((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(method: string): Promise; - protected sendRequest(method: string, params: unknown): Promise; - protected async sendRequest(method: string, ...args: [] | [unknown]): Promise { - 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((_, 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(method) - : this.connection.sendRequest(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; - protected sendNotification(method: string, params: unknown): Promise; - protected async sendNotification(method: string, ...args: [] | [unknown]): Promise { - 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 { - if (this.connection) { - try { - await this.sendRequest("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((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((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"; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts deleted file mode 100644 index 9fe67afe8..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts +++ /dev/null @@ -1,126 +0,0 @@ -export interface LspServerConfig { - id: string; - command: string[]; - extensions: string[]; - disabled?: boolean; - env?: Record; - initialization?: Record; -} - -export interface ResolvedServer { - id: string; - command: string[]; - extensions: string[]; - priority: number; - env?: Record; - initialization?: Record; -} - -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"; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts deleted file mode 100644 index 94428b2e0..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts +++ /dev/null @@ -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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts deleted file mode 100644 index fccf7bfb3..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts +++ /dev/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; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts deleted file mode 100644 index 54101b7b0..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts +++ /dev/null @@ -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 = {}): 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; - } -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts deleted file mode 100644 index 68fd9993a..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts +++ /dev/null @@ -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) => 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; - serverInfo?: Record; - 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; - 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 { - 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 { - 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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function messageFromError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts deleted file mode 100644 index 872392395..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts +++ /dev/null @@ -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; - required?: string[]; - items?: JsonSchema; - enum?: string[]; -} - -export interface LspMcpTool { - name: string; - aliases?: string[]; - title: string; - description: string; - inputSchema: JsonSchema; - execute(params: Record, signal?: AbortSignal): Promise; -} - -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; - 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; - 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, 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function requireString(params: Record, 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, key: string): string | undefined { - const value = params[key]; - return typeof value === "string" ? value : undefined; -} - -function requireNumber(params: Record, 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, key: string): number | undefined { - const value = params[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function optionalBoolean(params: Record, 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): 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 { - 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, - signal?: AbortSignal, -): Promise { - 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, - signal?: AbortSignal, -): Promise { - 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, - signal?: AbortSignal, -): Promise { - 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, signal?: AbortSignal): Promise { - 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, - 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, - signal?: AbortSignal, -): Promise { - 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, signal?: AbortSignal): Promise { - 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, - signal?: AbortSignal, -): Promise { - 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 { - 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, - }, -]; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts deleted file mode 100644 index 1bffd6cef..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts +++ /dev/null @@ -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; - } - } - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts deleted file mode 100644 index cbd85cae7..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py deleted file mode 100644 index 745b30daf..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py +++ /dev/null @@ -1 +0,0 @@ -value: str = 1 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts deleted file mode 100644 index 61fbf6365..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts +++ /dev/null @@ -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]]); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts deleted file mode 100644 index 6d199210a..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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, - }; -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts deleted file mode 100644 index 5b9e8d471..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts +++ /dev/null @@ -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 { - const body = JSON.stringify(message); - return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; -} - -function readOneMessage(stream: PassThrough): Promise> { - 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); - }); - }); -} - -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 }>("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(); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts deleted file mode 100644 index 4242e2622..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts deleted file mode 100644 index fce1bf577..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts deleted file mode 100644 index bc6196fc7..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts +++ /dev/null @@ -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; - readonly files: readonly string[]; - readonly dependencies?: Record; -}; - -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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/process.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/process.test.ts deleted file mode 100644 index c71a0c0db..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/process.test.ts +++ /dev/null @@ -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 { - 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 { - 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"); - } - }, - ); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts deleted file mode 100644 index 5fb9bdbac..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts deleted file mode 100644 index 614743d1d..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.build.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.build.json deleted file mode 100644 index 5b5bbcafd..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "allowImportingTsExtensions": false, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "noEmit": false - }, - "include": ["src/**/*"], - "exclude": ["test/**/*"] -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.json deleted file mode 100644 index 342229c02..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.json +++ /dev/null @@ -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/**/*"] -} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/vitest.config.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/vitest.config.ts deleted file mode 100644 index 57bd8f12b..000000000 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["test/**/*.test.ts"], - environment: "node", - pool: "threads", - }, -}); diff --git a/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs b/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs deleted file mode 100644 index a1b45e3de..000000000 --- a/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs +++ /dev/null @@ -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."); diff --git a/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-tools.mjs b/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-tools.mjs new file mode 100644 index 000000000..28508d11c --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-tools.mjs @@ -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."); diff --git a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts index 260a745b4..079f6ddeb 100644 --- a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +++ b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts @@ -9,12 +9,6 @@ type PackageJson = { readonly dependencies: Record; }; -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); diff --git a/packages/omo-codex/scripts/install-bin-links.test.mjs b/packages/omo-codex/scripts/install-bin-links.test.mjs new file mode 100644 index 000000000..27644b4bf --- /dev/null +++ b/packages/omo-codex/scripts/install-bin-links.test.mjs @@ -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/); +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index 0065d5a7e..b7cbd9be1 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -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 }); diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs index da0e5c3f9..db07ab39d 100644 --- a/packages/omo-codex/scripts/install-local.test.mjs +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -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/); -}); diff --git a/packages/omo-codex/scripts/install-test-fixtures.mjs b/packages/omo-codex/scripts/install-test-fixtures.mjs index 01fd378d4..8f45293fa 100644 --- a/packages/omo-codex/scripts/install-test-fixtures.mjs +++ b/packages/omo-codex/scripts/install-test-fixtures.mjs @@ -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 }); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs index bc28bfa12..c5e036fe8 100644 --- a/packages/omo-codex/scripts/install/cache.mjs +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -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 }); diff --git a/packages/omo-codex/scripts/install/config.mjs b/packages/omo-codex/scripts/install/config.mjs index e01510f89..27976f444 100644 --- a/packages/omo-codex/scripts/install/config.mjs +++ b/packages/omo-codex/scripts/install/config.mjs @@ -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) { diff --git a/packages/omo-codex/scripts/install/marketplace.mjs b/packages/omo-codex/scripts/install/marketplace.mjs index 12ab8358e..1d89bd284 100644 --- a/packages/omo-codex/scripts/install/marketplace.mjs +++ b/packages/omo-codex/scripts/install/marketplace.mjs @@ -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) { diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts index 64f2bd5f0..a3d35d793 100644 --- a/src/cli/install-codex/codex-cache.ts +++ b/src/cli/install-codex/codex-cache.ts @@ -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 { + 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 diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts index 87185b4b1..856453753 100644 --- a/src/cli/install-codex/codex-config-toml.test.ts +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -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({ diff --git a/src/cli/install-codex/codex-config-toml.ts b/src/cli/install-codex/codex-config-toml.ts index ce148604c..d3b7aa168 100644 --- a/src/cli/install-codex/codex-config-toml.ts +++ b/src/cli/install-codex/codex-config-toml.ts @@ -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 { 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 { diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts index 2bc5133b3..03f719425 100644 --- a/src/cli/install-codex/install-codex.test.ts +++ b/src/cli/install-codex/install-codex.test.ts @@ -2,7 +2,7 @@ /// 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() }) }) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 58998027f..31e143e40 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -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 { 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) {