From 186fade2e560762933ed5df6d3096f4045863109 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 13:23:40 +0900 Subject: [PATCH] feat(omo-claude): vendor lsp component Builds to dist/cli.js; hook bundled self-contained by sync-mcp. dist/ ignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugin/components/lsp/hooks/hooks.json | 17 ++ .../plugin/components/lsp/package.json | 64 +++++++ .../lsp/scripts/build-lsp-tools.mjs | 31 ++++ .../plugin/components/lsp/skills/lsp/SKILL.md | 35 ++++ .../plugin/components/lsp/src/cli.ts | 33 ++++ .../plugin/components/lsp/src/codex-hook.ts | 171 ++++++++++++++++++ .../plugin/components/lsp/tsconfig.build.json | 12 ++ .../plugin/components/lsp/tsconfig.json | 27 +++ 8 files changed, 390 insertions(+) create mode 100644 packages/omo-claude/plugin/components/lsp/hooks/hooks.json create mode 100644 packages/omo-claude/plugin/components/lsp/package.json create mode 100644 packages/omo-claude/plugin/components/lsp/scripts/build-lsp-tools.mjs create mode 100644 packages/omo-claude/plugin/components/lsp/skills/lsp/SKILL.md create mode 100644 packages/omo-claude/plugin/components/lsp/src/cli.ts create mode 100644 packages/omo-claude/plugin/components/lsp/src/codex-hook.ts create mode 100644 packages/omo-claude/plugin/components/lsp/tsconfig.build.json create mode 100644 packages/omo-claude/plugin/components/lsp/tsconfig.json diff --git a/packages/omo-claude/plugin/components/lsp/hooks/hooks.json b/packages/omo-claude/plugin/components/lsp/hooks/hooks.json new file mode 100644 index 000000000..8a7c87d86 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^(apply_patch|Write|Edit|MultiEdit|multi_edit|write|edit|multiedit)$", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", + "timeout": 60, + "statusMessage": "checking LSP diagnostics" + } + ] + } + ] + } +} diff --git a/packages/omo-claude/plugin/components/lsp/package.json b/packages/omo-claude/plugin/components/lsp/package.json new file mode 100644 index 000000000..b61ad07cc --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/package.json @@ -0,0 +1,64 @@ +{ + "name": "@code-yeongyu/codex-lsp", + "version": "0.2.0", + "description": "Codex plugin that exposes Language Server Protocol tools and post-edit diagnostics.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-lsp", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-lsp.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-lsp/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "lsp", + "language-server-protocol", + "mcp", + "diagnostics" + ], + "bin": { + "codex-lsp": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + ".codex-plugin", + ".mcp.json", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "bootstrap": "node scripts/build-lsp-tools.mjs", + "prebuild": "node scripts/build-lsp-tools.mjs", + "build": "tsc -p tsconfig.build.json", + "pretest": "node scripts/build-lsp-tools.mjs", + "test": "vitest --run", + "test:watch": "vitest", + "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/build-lsp-tools.mjs", + "check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json" + }, + "dependencies": { + "@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp" + }, + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/omo-claude/plugin/components/lsp/scripts/build-lsp-tools.mjs b/packages/omo-claude/plugin/components/lsp/scripts/build-lsp-tools.mjs new file mode 100644 index 000000000..28508d11c --- /dev/null +++ b/packages/omo-claude/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-claude/plugin/components/lsp/skills/lsp/SKILL.md b/packages/omo-claude/plugin/components/lsp/skills/lsp/SKILL.md new file mode 100644 index 000000000..36be06844 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/skills/lsp/SKILL.md @@ -0,0 +1,35 @@ +--- +name: lsp +description: Use when Codex needs language-server diagnostics, definitions, references, symbols, or rename safety checks in the current workspace. +--- + +# Codex LSP + +Call `lsp` MCP tools through the tool interface; `lsp.*`/`mcp__lsp__*` are tool-call names, not shell commands. + +## Tools + +- `lsp.status`: list configured, installed, missing, disabled, and active language servers. +- `lsp.diagnostics`: check one file or directory for LSP diagnostics. Prefer `severity: "error"` after edits. +- `lsp.goto_definition`: locate a symbol definition from file, line, and character. +- `lsp.find_references`: find usages of a symbol across the workspace. +- `lsp.symbols`: inspect document symbols or search workspace symbols. +- `lsp.prepare_rename`: check whether a rename is valid at a position. +- `lsp.rename`: apply a language-server workspace edit for a rename. + +## Config + +Project config lives at `.codex/lsp-client.json`; user config lives at `~/.codex/lsp-client.json`. + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +Use `lsp.status` first when diagnostics report a missing language server. diff --git a/packages/omo-claude/plugin/components/lsp/src/cli.ts b/packages/omo-claude/plugin/components/lsp/src/cli.ts new file mode 100644 index 000000000..9373ad7d9 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/cli.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; + +import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"; +import { runMcpStdioServer } from "@code-yeongyu/lsp-tools-mcp/dist/mcp.js"; +import { runPostToolUseHookCli } from "./codex-hook.js"; + +async function main(): Promise { + const [command = "mcp", subcommand = ""] = argv.slice(2); + + try { + if (command === "hook" && subcommand === "post-tool-use") { + await runPostToolUseHookCli(); + return; + } + + if (command === "mcp") { + await runMcpStdioServer(); + return; + } + + stderr.write("Usage: codex-lsp [mcp | hook post-tool-use]\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-claude/plugin/components/lsp/src/codex-hook.ts b/packages/omo-claude/plugin/components/lsp/src/codex-hook.ts new file mode 100644 index 000000000..c5ea956fc --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/codex-hook.ts @@ -0,0 +1,171 @@ +import { stdin as processStdin } from "node:process"; + +import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js"; + +export type DiagnosticsRunner = (filePath: string) => Promise; + +export interface CodexPostToolUseInput { + tool_name?: unknown; + tool_input?: unknown; + tool_response?: unknown; +} + +interface DiagnosticBlock { + filePath: string; + diagnostics: string; +} + +interface PostToolUseHookOutput { + decision: "block"; + reason: string; + hookSpecificOutput: { + hookEventName: "PostToolUse"; + additionalContext: string; + }; +} + +const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]); +const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found"; +const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:"; + +export async function runLspDiagnosticsText(filePath: string): Promise { + const result = await executeLspDiagnostics({ filePath, severity: "error" }); + return result.content.map((block) => block.text).join("\n"); +} + +export async function runLspPostToolUseHook( + input: CodexPostToolUseInput, + runDiagnostics: DiagnosticsRunner = runLspDiagnosticsText, +): Promise { + const filePaths = extractMutatedFilePaths(input); + if (filePaths.length === 0) return ""; + + const blocks: DiagnosticBlock[] = []; + for (const filePath of filePaths) { + const diagnostics = (await runDiagnostics(filePath)).trim(); + if (isCleanDiagnostics(diagnostics)) continue; + blocks.push({ filePath, diagnostics }); + } + + if (blocks.length === 0) return ""; + + const reason = blocks + .map(({ filePath, diagnostics }) => `LSP diagnostics after editing ${filePath}:\n${diagnostics}`) + .join("\n\n"); + const output: PostToolUseHookOutput = { + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: reason, + }, + }; + return `${JSON.stringify(output)}\n`; +} + +export function extractMutatedFilePaths(input: CodexPostToolUseInput): string[] { + if (!isMutationTool(input.tool_name)) return []; + if (isFailedToolResponse(input.tool_response)) return []; + + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + const paths = new Set(); + addStringValue(paths, toolInput["path"]); + addStringValue(paths, toolInput["filePath"]); + addStringValue(paths, toolInput["file_path"]); + addStringArray(paths, toolInput["paths"]); + addStringArray(paths, toolInput["filePaths"]); + addStringArray(paths, toolInput["file_paths"]); + addPatchPayloads(paths, toolInput); + addPatchFiles(paths, toolInput["files"]); + addPatchFiles(paths, toolInput["changes"]); + return [...paths]; +} + +export async function runPostToolUseHookCli(stdin: NodeJS.ReadStream = processStdin): Promise { + const raw = await readStdin(stdin); + if (!raw.trim()) return; + const parsed: unknown = JSON.parse(raw); + const input = isRecord(parsed) ? parsed : {}; + const output = await runLspPostToolUseHook(input); + if (output) process.stdout.write(output); +} + +function isMutationTool(value: unknown): boolean { + if (typeof value !== "string") return false; + return MUTATION_TOOL_NAMES.has(value.toLowerCase()); +} + +function isCleanDiagnostics(diagnostics: string): boolean { + return ( + diagnostics.length === 0 || + diagnostics === CLEAN_DIAGNOSTICS_TEXT || + diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT) + ); +} + +function isFailedToolResponse(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error" + ); +} + +function addStringValue(paths: Set, value: unknown): void { + if (typeof value === "string" && value.length > 0) { + paths.add(value); + } +} + +function addStringArray(paths: Set, value: unknown): void { + if (!Array.isArray(value)) return; + for (const item of value) { + addStringValue(paths, item); + } +} + +function addPatchPayloads(paths: Set, input: Record): void { + addPatchInput(paths, input["input"]); + addPatchInput(paths, input["patch"]); + addPatchInput(paths, input["command"]); +} + +function addPatchInput(paths: Set, value: unknown): void { + if (typeof value !== "string") return; + for (const line of value.split("\n")) { + const path = extractPatchHeaderPath(line); + if (path !== undefined) paths.add(path); + } +} + +function extractPatchHeaderPath(line: string): string | undefined { + const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "] as const; + for (const prefix of prefixes) { + if (line.startsWith(prefix)) return line.slice(prefix.length).trim(); + } + return undefined; +} + +function addPatchFiles(paths: Set, value: unknown): void { + if (!Array.isArray(value)) return; + for (const item of value) { + if (!isRecord(item)) continue; + addStringValue(paths, item["path"]); + addStringValue(paths, item["filePath"]); + addStringValue(paths, item["file_path"]); + addStringValue(paths, item["movePath"]); + addStringValue(paths, item["move_path"]); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readStdin(stdin: NodeJS.ReadStream): Promise { + stdin.setEncoding("utf8"); + let raw = ""; + for await (const chunk of stdin) { + raw += chunk; + } + return raw; +} diff --git a/packages/omo-claude/plugin/components/lsp/tsconfig.build.json b/packages/omo-claude/plugin/components/lsp/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-claude/plugin/components/lsp/tsconfig.json b/packages/omo-claude/plugin/components/lsp/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "useDefineForClassFields": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +}