diff --git a/package.json b/package.json index 6f91e6380..e1b9c575a 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,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/git-bash-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 run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.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 packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", + "test:codex": "bun run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/install-codex-packaged.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 packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-packaged-local.test.mjs packages/omo-codex/scripts/install-agent-links.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", "build:git-bash-mcp": "bun run --cwd packages/git-bash-mcp build" diff --git a/packages/omo-codex/plugin/.mcp.json b/packages/omo-codex/plugin/.mcp.json index 10e12ade7..84f104da2 100644 --- a/packages/omo-codex/plugin/.mcp.json +++ b/packages/omo-codex/plugin/.mcp.json @@ -5,6 +5,12 @@ "args": ["../../ast-grep-mcp/dist/cli.js", "mcp"], "cwd": "." }, + "grep_app": { + "url": "https://mcp.grep.app" + }, + "context7": { + "url": "https://mcp.context7.com/mcp" + }, "git_bash": { "command": "node", "args": ["../../git-bash-mcp/dist/cli.js", "mcp"], diff --git a/packages/omo-codex/plugin/components/ultrawork/agents/librarian.toml b/packages/omo-codex/plugin/components/ultrawork/agents/librarian.toml index 11e3819d3..422ef00dd 100644 --- a/packages/omo-codex/plugin/components/ultrawork/agents/librarian.toml +++ b/packages/omo-codex/plugin/components/ultrawork/agents/librarian.toml @@ -55,7 +55,7 @@ If the user names a version ("React 18", "Next.js 14", "v2.x"): ## Step 4 - targeted investigation - `webfetch()`. -- If a docs-indexer / library-index tool is available, query it for the specific topic. Otherwise rely on the sitemap-driven webfetch pages. +- If `context7` is available, query it for the specific topic. Otherwise rely on the sitemap-driven webfetch pages. ## Skip Phase 0.5 when - TYPE B (implementation) - you're cloning the repo anyway. @@ -70,7 +70,7 @@ If the user names a version ("React 18", "Next.js 14", "v2.x"): Run Phase 0.5 first, then in parallel: - `web_search` for current-year usage examples + best practices. - `webfetch` for the targeted doc pages identified by the sitemap. -- `gh search code "" --language ` for real-world code samples. +- `grep_app` for broad GitHub code search; fall back to `gh search code "" --language `. ## TYPE B - IMPLEMENTATION REFERENCE Execute in sequence: @@ -81,9 +81,9 @@ Execute in sequence: Parallel acceleration (4+ calls in one batch when independent): - Shallow clone. -- `gh search code "" --repo /`. +- `grep_app` broad code search or `gh search code "" --repo /`. - `gh api repos///commits/HEAD --jq '.sha'`. -- Sitemap-targeted `webfetch` of the relevant docs page for the same API surface. +- `context7` or sitemap-targeted `webfetch` of the relevant docs page for the same API surface. ## TYPE C - CONTEXT & HISTORY Execute in parallel (4+ calls): @@ -100,7 +100,7 @@ For a specific issue / PR: ## TYPE D - COMPREHENSIVE Run Phase 0.5 first, then execute 6+ parallel calls: - 2 docs calls: `webfetch` targeted doc pages + (if available) a docs-indexer query. -- 2 code-search calls: `gh search code` with varied queries (different angles). +- 2 code-search calls: `grep_app` or `gh search code` with varied queries (different angles). - 1 source clone for deep inspection. - 1 issues/PRs query for context. @@ -147,8 +147,9 @@ Never link to a branch name (`/blob/main/...`) - always pin to a SHA so the line - Sitemap -> `webfetch(/sitemap.xml)` (fallbacks: `/sitemap-0.xml`, `/sitemap_index.xml`). - Read a specific page -> `webfetch()`. - Latest info -> `web_search(" ")`. -- Code search (fast, broad) -> `gh search code "" --language ` (org-wide or repo-scoped). -- Code search (deep, repo-scoped) -> after cloning, `rg` / `ast_grep_search` over the clone. +- Docs index -> `context7` when available; use sitemap-driven pages when it is not. +- Code search (fast, broad) -> `grep_app` for web-scale GitHub search; `gh search code "" --language ` when you need GitHub CLI filters. +- Code search (deep, repo-scoped) -> after cloning, `rg` / `ast_grep` over the clone. - Clone -> `gh repo clone / "${TMPDIR:-/tmp}/" -- --depth 1`. - Issues / PRs -> `gh search issues|prs`, `gh issue|pr view --comments`. - Release info -> `gh api repos///releases/latest`. diff --git a/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts index a942b0ba5..b3ef51c80 100644 --- a/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts +++ b/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts @@ -34,6 +34,31 @@ describe("codex ultrawork package metadata", () => { expect(hookCommands).toContain(`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`); expect(hookCommands).not.toContainEqual(expect.stringMatching(/\bpython3?\b|ultrawork-detector\.py/)); }); + + it("#given explorer guidance #when inspected #then names the packaged code-search MCP surface", () => { + // given + const explorer = readFileSync("agents/explorer.toml", "utf8"); + + // when + const guidance = explorer.toLowerCase(); + + // then + expect(guidance).toContain("ast_grep"); + expect(guidance).toContain("structural"); + }); + + it("#given librarian guidance #when inspected #then names the packaged research MCP surfaces", () => { + // given + const librarian = readFileSync("agents/librarian.toml", "utf8"); + + // when + const guidance = librarian.toLowerCase(); + + // then + expect(guidance).toContain("grep_app"); + expect(guidance).toContain("context7"); + expect(guidance).toContain("ast_grep"); + }); }); function readJson(path: string): unknown { diff --git a/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs b/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs new file mode 100644 index 000000000..c56cb7f91 --- /dev/null +++ b/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +test("#given aggregate MCP config #when inspected #then registers research and structural-search MCPs", async () => { + // given + const mcp = JSON.parse(await readFile(join(root, ".mcp.json"), "utf8")); + + // when + const serverNames = Object.keys(mcp.mcpServers).sort(); + + // then + assert.deepEqual(serverNames, ["ast_grep", "context7", "git_bash", "grep_app", "lsp"]); + assert.equal(mcp.mcpServers.grep_app.url, "https://mcp.grep.app"); + assert.equal(mcp.mcpServers.context7.url, "https://mcp.context7.com/mcp"); + assert.deepEqual(mcp.mcpServers.ast_grep.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index 210618b91..81f0e7aa4 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { mkdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -46,6 +47,7 @@ export async function installMarketplaceLocally(options = {}) { const platform = options.platform ?? process.platform; const runCommand = options.runCommand ?? defaultRunCommand; const log = options.log ?? console.log; + const buildSource = await shouldBuildSourcePackages(repoRoot); const gitBashResolution = await prepareGitBashForInstall({ platform, env, @@ -79,6 +81,7 @@ export async function installMarketplaceLocally(options = {}) { log(`Building ${entry.name}@${version}`); const plugin = await installCachedPlugin({ + buildSource, codexHome, marketplaceName: marketplace.name, name: entry.name, @@ -191,6 +194,14 @@ function legacyCacheMarketplaces(marketplaceName) { return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []; } +async function shouldBuildSourcePackages(repoRoot) { + if (existsSync(join(repoRoot, "src", "index.ts"))) return true; + const packageJsonPath = join(repoRoot, "package.json"); + if (!existsSync(packageJsonPath)) return true; + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + return !["@code-yeongyu/lazycodex", "lazycodex", "oh-my-opencode", "oh-my-openagent"].includes(packageJson?.name); +} + 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-mcp-runtime.test.mjs b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs index fc07b358c..9812a0ede 100644 --- a/packages/omo-codex/scripts/install-mcp-runtime.test.mjs +++ b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs @@ -188,3 +188,46 @@ test("#given structurally valid external MCP package without mcp suffix #when in assert.deepEqual(cachedMcp.mcpServers.language_tools.args, [copiedCli, "mcp", join(sourceRoot, "..", "local-config.json")]); assert.equal((await stat(copiedCli)).isFile(), true); }); + +test("#given packaged external MCP runtime has only dist files #when installing cached plugin #then runtime is copied into the plugin cache", async () => { + // given + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); + const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp"); + + await writeJson(join(sourceRoot, "package.json"), { + name: "@example/omo", + version: "0.1.0", + }); + await writeJson(join(sourceRoot, ".mcp.json"), { + mcpServers: { + lsp: { + command: "node", + args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await writeJson(join(lspPackageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(lspPackageRoot, "dist", "lsp", "manager.js"), { copied: true }); + + // when + const result = await installCachedPlugin({ + codexHome, + marketplaceName: "sisyphuslabs", + name: "omo", + runCommand: async () => {}, + sourcePath: sourceRoot, + version: "0.1.0", + }); + + // then + const cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8")); + const copiedCli = join(result.path, "mcp", "lsp", "dist", "cli.js"); + assert.deepEqual(cachedMcp.mcpServers.lsp.args, [copiedCli, "mcp"]); + assert.equal(Object.hasOwn(cachedMcp.mcpServers.lsp, "cwd"), false); + assert.equal((await stat(copiedCli)).isFile(), true); + assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true); + assert.notEqual(cachedMcp.mcpServers.lsp.args[0], join(lspPackageRoot, "dist", "cli.js")); +}); diff --git a/packages/omo-codex/scripts/install-packaged-local.test.mjs b/packages/omo-codex/scripts/install-packaged-local.test.mjs new file mode 100644 index 000000000..cedee6a87 --- /dev/null +++ b/packages/omo-codex/scripts/install-packaged-local.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { installMarketplaceLocally } from "./install-local.mjs"; +import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs"; + +test("#given packaged lazycodex adapter #when installing locally #then uses bundled artifacts without source builds", async () => { + // given + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const binDir = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + const lspRuntimeRoot = join(repoRoot, "packages", "lsp-tools-mcp"); + + await writeJson(join(repoRoot, "package.json"), { + name: "@code-yeongyu/lazycodex", + version: "0.1.2", + }); + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + await writeJson(join(pluginRoot, ".mcp.json"), { + mcpServers: { + lsp: { + command: "node", + args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\nconsole.log('prebuilt')\n"); + await writeJson(join(lspRuntimeRoot, "dist", "cli.js"), { executable: true }); + + const commands = []; + + // when + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + binDir, + platform: "linux", + runCommand: async (command, args, options) => { + commands.push([command, args.join(" "), options.cwd]); + }, + log: () => {}, + }); + + // then + const pluginCacheRoot = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "0.1.0"); + const cachedMcp = JSON.parse(await readFile(join(pluginCacheRoot, ".mcp.json"), "utf8")); + const cachedLspCli = join(pluginCacheRoot, "mcp", "lsp", "dist", "cli.js"); + + assert.deepEqual(result.installed.map((plugin) => `${plugin.name}@${plugin.version}`), ["omo@0.1.0"]); + assert.deepEqual( + commands.map(([command, args, cwd]) => [command, args, cwd]), + [["npm", "install --omit=dev", pluginCacheRoot]], + ); + assert.deepEqual(cachedMcp.mcpServers.lsp.args, [cachedLspCli, "mcp"]); + assert.equal((await stat(cachedLspCli)).isFile(), true); + assert.notEqual(cachedMcp.mcpServers.lsp.args[0], join(lspRuntimeRoot, "dist", "cli.js")); +}); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs index c04315e80..041bc719d 100644 --- a/packages/omo-codex/scripts/install/cache.mjs +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -6,9 +6,11 @@ import { exists, isRecord } from "./utils.mjs"; import { COMMAND_SHIM_MARKER } from "./command-shim.mjs"; import { removeLegacyCodexComponentBins } from "./legacy-bins.mjs"; -export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) { - await maybeRunNpmInstall(sourcePath, runCommand); - await maybeRunNpmBuild(sourcePath, runCommand); +export async function installCachedPlugin({ buildSource = true, codexHome, marketplaceName, name, runCommand, sourcePath, version }) { + if (buildSource) { + await maybeRunNpmInstall(sourcePath, runCommand); + await maybeRunNpmBuild(sourcePath, runCommand); + } const targetPath = join(codexHome, "plugins", "cache", marketplaceName, name, version); await replaceDirectory(sourcePath, targetPath, shouldCopyPluginPath); diff --git a/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs b/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs index 79d52b96e..deaebb4f7 100644 --- a/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs +++ b/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs @@ -40,7 +40,7 @@ function resolveExternalMcpPackageRoot(runtimePath, sourceRoot) { if (!isPathInside(runtimePath, packagesRoot)) return undefined; let packageRoot = dirname(runtimePath); while (packageRoot !== packagesRoot) { - if (existsSync(join(packageRoot, "package.json")) && isPathInside(runtimePath, join(packageRoot, "dist"))) { + if (isPathInside(runtimePath, join(packageRoot, "dist")) && isRuntimePackageRoot(packageRoot)) { return packageRoot; } const parent = dirname(packageRoot); @@ -50,6 +50,10 @@ function resolveExternalMcpPackageRoot(runtimePath, sourceRoot) { return undefined; } +function isRuntimePackageRoot(packageRoot) { + return existsSync(join(packageRoot, "package.json")) || existsSync(join(packageRoot, "dist")); +} + function findPackagesRoot(path) { let current = resolve(path); for (let index = 0; index < 8; index++) { diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts index 1a6b20b29..5af8a9fd4 100644 --- a/src/cli/install-codex/codex-cache.ts +++ b/src/cli/install-codex/codex-cache.ts @@ -10,6 +10,7 @@ import type { InstalledPlugin, RunCommand } from "./types" type LinkPlatform = NodeJS.Platform export async function installCachedPlugin(input: { + readonly buildSource?: boolean readonly codexHome: string readonly marketplaceName: string readonly name: string @@ -17,8 +18,10 @@ export async function installCachedPlugin(input: { readonly version: string readonly runCommand: RunCommand }): Promise { - await maybeRunNpmInstall(input.sourcePath, input.runCommand) - await maybeRunNpmBuild(input.sourcePath, input.runCommand) + if (input.buildSource !== false) { + await maybeRunNpmInstall(input.sourcePath, input.runCommand) + await maybeRunNpmBuild(input.sourcePath, input.runCommand) + } const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version) await replaceDirectory(input.sourcePath, targetPath) diff --git a/src/cli/install-codex/codex-package-layout.ts b/src/cli/install-codex/codex-package-layout.ts new file mode 100644 index 000000000..520c05e02 --- /dev/null +++ b/src/cli/install-codex/codex-package-layout.ts @@ -0,0 +1,18 @@ +import { existsSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { join } from "node:path" + +const PACKAGED_CODEX_INSTALLER_NAMES = new Set(["@code-yeongyu/lazycodex", "lazycodex", "oh-my-opencode", "oh-my-openagent"]) + +export async function shouldBuildSourcePackages(repoRoot: string): Promise { + if (existsSync(join(repoRoot, "src", "index.ts"))) return true + const packageJsonPath = join(repoRoot, "package.json") + if (!existsSync(packageJsonPath)) return true + const packageJson: unknown = JSON.parse(await readFile(packageJsonPath, "utf8")) + if (!isRecord(packageJson) || typeof packageJson.name !== "string") return true + return !PACKAGED_CODEX_INSTALLER_NAMES.has(packageJson.name) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/install-codex-mcp-manifest.test.ts b/src/cli/install-codex/install-codex-mcp-manifest.test.ts new file mode 100644 index 000000000..4aee8ade8 --- /dev/null +++ b/src/cli/install-codex/install-codex-mcp-manifest.test.ts @@ -0,0 +1,40 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runCodexInstaller } from "./install-codex" + +type CachedMcpManifest = { + readonly mcpServers: { + readonly ast_grep: { readonly args: readonly string[] } + readonly context7: { readonly url: string } + readonly grep_app: { readonly url: string } + } +} + +describe("install-codex MCP manifest", () => { + test("#given codex installer #when installing omo #then caches research and structural-search MCPs", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-mcp-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-mcp-")) + + // when + const result = await runCodexInstaller({ + codexHome, + binDir, + repoRoot: process.cwd(), + runCommand: async () => undefined, + }) + + // then + const pluginPath = result.installed[0]?.path ?? "" + const manifest = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as CachedMcpManifest + expect(manifest.mcpServers.grep_app.url).toBe("https://mcp.grep.app") + expect(manifest.mcpServers.context7.url).toBe("https://mcp.context7.com/mcp") + expect(manifest.mcpServers.ast_grep.args[0]).toBe(join(pluginPath, "components", "ast-grep-mcp", "dist", "cli.js")) + expect((await stat(manifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) + }) +}) diff --git a/src/cli/install-codex/install-codex-packaged.test.ts b/src/cli/install-codex/install-codex-packaged.test.ts new file mode 100644 index 000000000..11635084a --- /dev/null +++ b/src/cli/install-codex/install-codex-packaged.test.ts @@ -0,0 +1,72 @@ +/// +/// + +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, readFile, readlink, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runCodexInstaller } from "./install-codex" + +test("#given packaged lazycodex tarball layout #when installing Codex plugin #then uses bundled artifacts without source builds", async () => { + // given + const repoRoot = await mkdtemp(join(tmpdir(), "omo-codex-packaged-root-")) + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-packaged-home-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-packaged-bin-")) + const codexPackageRoot = join(repoRoot, "packages", "omo-codex") + const pluginRoot = join(codexPackageRoot, "plugin") + const lspRuntimeRoot = join(repoRoot, "packages", "lsp-tools-mcp") + const commands: Array = [] + + await writeFile(join(repoRoot, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "4.5.12" })) + await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true }) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await mkdir(join(lspRuntimeRoot, "dist"), { recursive: true }) + await writeFile( + join(codexPackageRoot, "marketplace.json"), + JSON.stringify({ name: "sisyphuslabs", plugins: [{ name: "omo", source: "./plugin" }] }), + ) + await writeFile( + join(pluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "omo", version: "0.1.0", hooks: "hooks/hooks.json" }), + ) + await writeFile( + join(pluginRoot, "package.json"), + JSON.stringify({ + name: "@sisyphuslabs/omo-codex-plugin", + version: "0.1.0", + bin: { omo: "dist/cli.js" }, + scripts: { build: "exit 42" }, + }), + ) + await writeFile( + join(pluginRoot, ".mcp.json"), + JSON.stringify({ mcpServers: { lsp: { command: "node", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], cwd: "." } } }), + ) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(join(lspRuntimeRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const result = await runCodexInstaller({ + codexHome, + binDir, + repoRoot, + platform: "linux", + runCommand: async (command, args, options) => { + commands.push([command, args.join(" "), options.cwd]) + }, + }) + + // then + const pluginPath = result.installed[0]?.path ?? "" + const cachedMcp = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as { + readonly mcpServers: { readonly lsp: { readonly args: readonly string[]; readonly cwd?: string } } + } + const cachedLspCli = join(pluginPath, "components", "lsp-tools-mcp", "dist", "cli.js") + + expect(commands).toEqual([["npm", "install --omit=dev", pluginPath]]) + expect(cachedMcp.mcpServers.lsp.cwd).toBeUndefined() + expect(cachedMcp.mcpServers.lsp.args).toEqual([cachedLspCli, "mcp"]) + expect(cachedMcp.mcpServers.lsp.args[0]).not.toBe(join(lspRuntimeRoot, "dist", "cli.js")) + expect((await stat(cachedLspCli)).isFile()).toBe(true) + expect(await readlink(join(binDir, "omo"))).toBe(join(pluginPath, "dist", "cli.js")) +}) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index ddd8a5448..b41905069 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path" import { existsSync } from "node:fs" import { mkdir, writeFile } from "node:fs/promises" import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache" +import { shouldBuildSourcePackages } from "./codex-package-layout" import { updateCodexConfig } from "./codex-config-toml" import { trustedHookStatesForPlugin } from "./codex-hook-trust" import { prepareGitBashForInstall, resolveGitBashForCurrentProcess } from "./git-bash" @@ -22,6 +23,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env }) const runCommand = options.runCommand ?? defaultRunCommand const log = options.log ?? (() => undefined) + const buildSource = await shouldBuildSourcePackages(repoRoot) const gitBashResolution = await prepareGitBashForInstall({ platform, @@ -58,6 +60,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom log(`Building ${entry.name}@${version}`) const plugin = await installCachedPlugin({ + buildSource, codexHome, marketplaceName: marketplace.name, name: entry.name, @@ -210,10 +213,6 @@ function legacyCacheMarketplaces(marketplaceName: string): readonly string[] { return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : [] } -function codexMarketplaceSource(marketplaceRoot: string): CodexMarketplaceSource { - return { sourceType: "local", source: marketplaceRoot } -} - export function findRepoRootFromImporter(importerDir: string): string { let current = importerDir for (let depth = 0; depth <= 5; depth += 1) { @@ -248,6 +247,10 @@ function existsSyncLike(path: string): boolean { return existsSync(path) } +function codexMarketplaceSource(marketplaceRoot: string): CodexMarketplaceSource { + return { sourceType: "local", source: marketplaceRoot } +} + async function trackCodexInstallTelemetry(): Promise { try { const { createInstallPostHog, getPostHogDistinctId } = await import("@oh-my-opencode/omo-codex/telemetry")