feat(omo-codex): embed ast-grep MCP for Codex

This commit is contained in:
YeonGyu-Kim
2026-05-28 15:26:18 +09:00
parent 091550b94d
commit 4f75a56ce5
8 changed files with 220 additions and 17 deletions
@@ -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<void> {
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<void> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -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))
}
+69 -1
View File
@@ -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<string, string>
}
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-"))
+6 -3
View File
@@ -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<void> {
export async function rewriteCachedMcpManifest(pluginRoot: string, sourceRoot = pluginRoot): Promise<void> {
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<void
if (!Array.isArray(currentArgs)) continue
const nextArgs = currentArgs.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 !== currentArgs[index])) {