From 4f75a56ce55c75d4eeb7d89198d8b255fe6d4736 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 28 May 2026 15:26:18 +0900 Subject: [PATCH] feat(omo-codex): embed ast-grep MCP for Codex --- packages/omo-codex/plugin/.mcp.json | 7 +- packages/omo-codex/plugin/package.json | 3 +- .../omo-codex/plugin/test/aggregate.test.mjs | 27 +++++-- packages/omo-codex/scripts/install/cache.mjs | 61 ++++++++++++++-- .../codex-cache-local-dependencies.ts | 48 +++++++++++++ src/cli/install-codex/codex-cache-paths.ts | 12 ++++ src/cli/install-codex/codex-cache.test.ts | 70 ++++++++++++++++++- src/cli/install-codex/codex-cache.ts | 9 ++- 8 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 src/cli/install-codex/codex-cache-local-dependencies.ts create mode 100644 src/cli/install-codex/codex-cache-paths.ts diff --git a/packages/omo-codex/plugin/.mcp.json b/packages/omo-codex/plugin/.mcp.json index b36e4a79b..b38f76a57 100644 --- a/packages/omo-codex/plugin/.mcp.json +++ b/packages/omo-codex/plugin/.mcp.json @@ -1,8 +1,13 @@ { "mcpServers": { + "ast_grep": { + "command": "node", + "args": ["../../ast-grep-mcp/dist/cli.js", "mcp"], + "cwd": "." + }, "lsp": { "command": "node", - "args": ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"], + "args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"], "cwd": "." } } diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json index 5b88cc7d7..844f1937f 100644 --- a/packages/omo-codex/plugin/package.json +++ b/packages/omo-codex/plugin/package.json @@ -9,13 +9,12 @@ "components/comment-checker", "components/rules", "components/lsp", - "components/lsp/packages/lsp-tools-mcp", "components/telemetry", "components/ultragoal", "components/ultrawork" ], "scripts": { - "build": "node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present", + "build": "bun run --cwd ../../ast-grep-mcp build && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present", "check": "npm run build && npm test", "sync:skills": "node scripts/sync-skills.mjs", "test": "node --test test/*.test.mjs" diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 5f9e1787c..c617aaa46 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -71,17 +71,29 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ult assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]); }); -test("#given aggregate MCP config #when inspected #then lsp server stays component isolated", async () => { +test("#given aggregate MCP config #when inspected #then code MCP servers reuse root MCP packages", async () => { // given + const packageJson = await readJson("package.json"); const mcp = await readJson(".mcp.json"); // when - const server = mcp.mcpServers.lsp; + const lspServer = mcp.mcpServers.lsp; + const astGrepServer = mcp.mcpServers.ast_grep; + const codeMcpNames = Object.keys(mcp.mcpServers) + .filter((name) => name === "lsp" || name === "ast_grep") + .sort(); // then - assert.equal(server.command, "node"); - assert.deepEqual(server.args, ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"]); - assert.equal(server.cwd, "."); + assert.deepEqual(codeMcpNames, ["ast_grep", "lsp"]); + assert.equal(packageJson.workspaces.includes("components/lsp/packages/lsp-tools-mcp"), false); + assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-mcp"), false); + assert.match(packageJson.scripts.build, /ast-grep-mcp/); + assert.equal(lspServer.command, "node"); + assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]); + assert.equal(lspServer.cwd, "."); + assert.equal(astGrepServer.command, "node"); + assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); + assert.equal(astGrepServer.cwd, "."); }); test("#given aggregate plugin build script #when inspected #then telemetry sync runs before workspace builds", async () => { @@ -93,7 +105,10 @@ test("#given aggregate plugin build script #when inspected #then telemetry sync const buildScript = packageJson.scripts.build; // then - assert.equal(buildScript, "node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present"); + assert.equal( + buildScript, + "bun run --cwd ../../ast-grep-mcp build && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present", + ); assert.match(telemetrySyncScript, /syncTelemetryComponent/); }); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs index 6d795e060..bc28bfa12 100644 --- a/packages/omo-codex/scripts/install/cache.mjs +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -1,4 +1,4 @@ -import { basename, dirname, join, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"; import { exists, isRecord } from "./utils.mjs"; @@ -11,8 +11,9 @@ export async function installCachedPlugin({ codexHome, marketplaceName, name, ru const targetPath = join(codexHome, "plugins", "cache", marketplaceName, name, version); await replaceDirectory(sourcePath, targetPath, shouldCopyPluginPath); + await rewriteCachedPackageLocalFileDependencies(targetPath, sourcePath); await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]); - await rewriteCachedMcpManifest(targetPath); + await rewriteCachedMcpManifest(targetPath, sourcePath); return { name, version, path: targetPath }; } @@ -157,7 +158,7 @@ function shouldCopyPluginPath(path, root) { return !parts.some((part) => part === ".git" || part === "node_modules"); } -async function rewriteCachedMcpManifest(pluginRoot) { +async function rewriteCachedMcpManifest(pluginRoot, sourceRoot = pluginRoot) { const manifestPath = join(pluginRoot, ".mcp.json"); if (!(await exists(manifestPath))) return; const raw = await readFile(manifestPath, "utf8"); @@ -173,7 +174,7 @@ async function rewriteCachedMcpManifest(pluginRoot) { if (!Array.isArray(server.args)) continue; const nextArgs = server.args.map((arg) => { if (typeof arg !== "string") return arg; - if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg); + if (arg.startsWith("./") || arg.startsWith("../")) return resolveCachedRuntimePath(pluginRoot, sourceRoot, arg); return arg; }); if (nextArgs.some((value, index) => value !== server.args[index])) { @@ -183,3 +184,55 @@ async function rewriteCachedMcpManifest(pluginRoot) { } if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`); } + +async function rewriteCachedPackageLocalFileDependencies(pluginRoot, sourceRoot) { + const packageJsonPaths = []; + await collectPackageJsonPaths(pluginRoot, pluginRoot, packageJsonPaths); + for (const packageJsonPath of packageJsonPaths) { + const raw = await readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) continue; + const packageDir = dirname(packageJsonPath); + const sourcePackageDir = join(sourceRoot, relative(pluginRoot, packageDir)); + let changed = false; + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + const dependencies = parsed[field]; + if (!isRecord(dependencies)) continue; + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue; + const filePath = specifier.slice("file:".length); + if (filePath.length === 0 || isAbsolute(filePath)) continue; + const targetPath = resolve(packageDir, filePath); + if (isPathInside(targetPath, pluginRoot)) continue; + dependencies[name] = `file:${resolve(sourcePackageDir, filePath)}`; + changed = true; + } + } + if (changed) await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`); + } +} + +async function collectPackageJsonPaths(directory, root, paths) { + const entries = await readdir(directory, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + paths.push(join(directory, "package.json")); + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue; + const childPath = join(directory, entry.name); + if (!childPath.startsWith(root)) continue; + await collectPackageJsonPaths(childPath, root, paths); + } +} + +function resolveCachedRuntimePath(pluginRoot, sourceRoot, runtimePath) { + const targetPath = resolve(pluginRoot, runtimePath); + if (isPathInside(targetPath, pluginRoot)) return targetPath; + return resolve(sourceRoot, runtimePath); +} + +function isPathInside(candidatePath, rootPath) { + const pathFromRoot = relative(rootPath, candidatePath); + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); +} diff --git a/src/cli/install-codex/codex-cache-local-dependencies.ts b/src/cli/install-codex/codex-cache-local-dependencies.ts new file mode 100644 index 000000000..ac4a8b1a4 --- /dev/null +++ b/src/cli/install-codex/codex-cache-local-dependencies.ts @@ -0,0 +1,48 @@ +import { readFile, readdir, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { isPathInside } from "./codex-cache-paths" + +export async function rewriteCachedPackageLocalFileDependencies(pluginRoot: string, sourceRoot: string): Promise { + const packageJsonPaths: string[] = [] + await collectPackageJsonPaths(pluginRoot, pluginRoot, packageJsonPaths) + for (const packageJsonPath of packageJsonPaths) { + const raw = await readFile(packageJsonPath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) continue + const packageDir = dirname(packageJsonPath) + const sourcePackageDir = join(sourceRoot, relative(pluginRoot, packageDir)) + let changed = false + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"] as const) { + const dependencies = parsed[field] + if (!isRecord(dependencies)) continue + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue + const filePath = specifier.slice("file:".length) + if (filePath.length === 0 || isAbsolute(filePath)) continue + const targetPath = resolve(packageDir, filePath) + if (isPathInside(targetPath, pluginRoot)) continue + dependencies[name] = `file:${resolve(sourcePackageDir, filePath)}` + changed = true + } + } + if (changed) await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`) + } +} + +async function collectPackageJsonPaths(directory: string, root: string, paths: string[]): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + paths.push(join(directory, "package.json")) + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue + const childPath = join(directory, entry.name) + if (!isPathInside(childPath, root)) continue + await collectPackageJsonPaths(childPath, root, paths) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-cache-paths.ts b/src/cli/install-codex/codex-cache-paths.ts new file mode 100644 index 000000000..64eb98d0d --- /dev/null +++ b/src/cli/install-codex/codex-cache-paths.ts @@ -0,0 +1,12 @@ +import { isAbsolute, relative, resolve } from "node:path" + +export function resolveCachedRuntimePath(pluginRoot: string, sourceRoot: string, runtimePath: string): string { + const targetPath = resolve(pluginRoot, runtimePath) + if (isPathInside(targetPath, pluginRoot)) return targetPath + return resolve(sourceRoot, runtimePath) +} + +export function isPathInside(candidatePath: string, rootPath: string): boolean { + const pathFromRoot = relative(rootPath, candidatePath) + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)) +} diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts index 66efe2f3d..49744331f 100644 --- a/src/cli/install-codex/codex-cache.test.ts +++ b/src/cli/install-codex/codex-cache.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test" import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { linkCachedPluginBins, rewriteCachedMcpManifest } from "./codex-cache" +import { installCachedPlugin, linkCachedPluginBins, rewriteCachedMcpManifest } from "./codex-cache" describe("codex-cache", () => { test("rewrites cached mcp manifest relative args and cwd", async () => { @@ -27,6 +27,74 @@ describe("codex-cache", () => { expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "./components/lsp/dist/cli.js")) }) + test("rewrites cached mcp manifest args that point outside the plugin cache back to the source package", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const sourceRoot = join(root, "packages", "omo-codex", "plugin") + const cacheRoot = join(root, "cache", "omo") + await mkdir(cacheRoot, { recursive: true }) + await writeFile( + join(cacheRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] }, + custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] }, + lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] }, + }, + }), + ) + + // when + await rewriteCachedMcpManifest(cacheRoot, sourceRoot) + + // then + const rewritten = JSON.parse(await readFile(join(cacheRoot, ".mcp.json"), "utf8")) as { + mcpServers: { + ast_grep: { cwd?: string; args: string[] } + custom: { args: string[] } + lsp: { cwd?: string; args: string[] } + } + } + expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "lsp"]) + expect(rewritten.mcpServers.ast_grep.cwd).toBeUndefined() + expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(root, "packages", "ast-grep-mcp", "dist", "cli.js")) + expect(rewritten.mcpServers.custom.args).toEqual(["/usr/local/bin/custom-mcp", "--stdio"]) + expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() + expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "packages", "lsp-tools-mcp", "dist", "cli.js")) + }) + + test("rewrites cached package file dependencies that point outside the plugin cache back to the source package", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const codexHome = join(root, "codex-home") + const sourceRoot = join(root, "packages", "omo-codex", "plugin") + await mkdir(sourceRoot, { recursive: true }) + await writeFile( + join(sourceRoot, "package.json"), + JSON.stringify({ + name: "@scope/omo", + version: "0.1.0", + dependencies: { "@scope/lsp-tools": "file:../lsp-tools-mcp" }, + }), + ) + + // when + const installed = await installCachedPlugin({ + codexHome, + marketplaceName: "debug", + name: "omo", + sourcePath: sourceRoot, + version: "0.1.0", + runCommand: async () => undefined, + }) + + // then + const cachedPackageJson = JSON.parse(await readFile(join(installed.path, "package.json"), "utf8")) as { + dependencies: Record + } + expect(cachedPackageJson.dependencies["@scope/lsp-tools"]).toBe(`file:${join(root, "packages", "omo-codex", "lsp-tools-mcp")}`) + }) + test("links cached plugin bins and stays idempotent", async () => { // given const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts index ce35bf840..64f2bd5f0 100644 --- a/src/cli/install-codex/codex-cache.ts +++ b/src/cli/install-codex/codex-cache.ts @@ -1,5 +1,7 @@ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises" import { basename, dirname, join, sep } from "node:path" +import { rewriteCachedPackageLocalFileDependencies } from "./codex-cache-local-dependencies" +import { resolveCachedRuntimePath } from "./codex-cache-paths" import type { InstalledPlugin, RunCommand } from "./types" type LinkPlatform = NodeJS.Platform @@ -19,8 +21,9 @@ export async function installCachedPlugin(input: { const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version) await replaceDirectory(input.sourcePath, targetPath) + await rewriteCachedPackageLocalFileDependencies(targetPath, input.sourcePath) await maybeRunNpmInstall(targetPath, input.runCommand, ["install", "--omit=dev"]) - await rewriteCachedMcpManifest(targetPath) + await rewriteCachedMcpManifest(targetPath, input.sourcePath) return { name: input.name, version: input.version, path: targetPath } } @@ -70,7 +73,7 @@ async function linkCachedPluginBin( return linkPath } -export async function rewriteCachedMcpManifest(pluginRoot: string): Promise { +export async function rewriteCachedMcpManifest(pluginRoot: string, sourceRoot = pluginRoot): Promise { const manifestPath = join(pluginRoot, ".mcp.json") if (!(await exists(manifestPath))) return const raw = await readFile(manifestPath, "utf8") @@ -87,7 +90,7 @@ export async function rewriteCachedMcpManifest(pluginRoot: string): Promise { if (typeof arg !== "string") return arg - if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg) + if (arg.startsWith("./") || arg.startsWith("../")) return resolveCachedRuntimePath(pluginRoot, sourceRoot, arg) return arg }) if (nextArgs.some((value, index) => value !== currentArgs[index])) {