From 22bcb4b45aea2e31c6ad96c1b99169edd4b31fd2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 11:18:47 +0900 Subject: [PATCH] fix(cli): write Codex bin command shims on Windows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/install-codex/codex-cache.test.ts | 20 ++++++++++++ src/cli/install-codex/codex-cache.ts | 37 +++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts index e08c10c8e..7ff1e11f8 100644 --- a/src/cli/install-codex/codex-cache.test.ts +++ b/src/cli/install-codex/codex-cache.test.ts @@ -44,4 +44,24 @@ describe("codex-cache", () => { const linkedTarget = await readlink(join(binDir, "omo-hook")) expect(linkedTarget).toBe(join(pluginRoot, "dist", "cli.js")) }) + + test("writes Windows command shims for cached plugin bins", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }) + + // then + expect(linked).toEqual([{ name: "omo-hook", path: join(binDir, "omo-hook.cmd"), target: join(pluginRoot, "dist", "cli.js") }]) + const commandShim = await readFile(join(binDir, "omo-hook.cmd"), "utf8") + expect(commandShim).toContain("@echo off") + expect(commandShim).toContain(`node "${join(pluginRoot, "dist", "cli.js")}" %*`) + }) }) diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts index 56c1d5034..082b20606 100644 --- a/src/cli/install-codex/codex-cache.ts +++ b/src/cli/install-codex/codex-cache.ts @@ -2,6 +2,8 @@ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, wri import { basename, dirname, join, sep } from "node:path" import type { InstalledPlugin, RunCommand } from "./types" +type LinkPlatform = NodeJS.Platform + export async function installCachedPlugin(input: { readonly codexHome: string readonly marketplaceName: string @@ -38,18 +40,34 @@ export async function pruneMarketplaceCache(input: { export async function linkCachedPluginBins(input: { readonly binDir: string readonly pluginRoot: string + readonly platform?: LinkPlatform }): Promise { const binLinks = await discoverPackageBins(input.pluginRoot) await mkdir(input.binDir, { recursive: true }) const linked: Array<{ name: string; path: string; target: string }> = [] for (const link of binLinks) { - const linkPath = join(input.binDir, link.name) - await replaceSymlink(linkPath, link.target) + const linkPath = await linkCachedPluginBin(input.binDir, link, input.platform ?? process.platform) linked.push({ name: link.name, path: linkPath, target: link.target }) } return linked } +async function linkCachedPluginBin( + binDir: string, + link: { readonly name: string; readonly target: string }, + platform: LinkPlatform, +): Promise { + if (platform === "win32") { + const linkPath = join(binDir, `${link.name}.cmd`) + await replaceCommandShim(linkPath, link.target) + return linkPath + } + + const linkPath = join(binDir, link.name) + await replaceSymlink(linkPath, link.target) + return linkPath +} + export async function rewriteCachedMcpManifest(pluginRoot: string): Promise { const manifestPath = join(pluginRoot, ".mcp.json") if (!(await exists(manifestPath))) return @@ -143,6 +161,21 @@ async function replaceSymlink(linkPath: string, targetPath: string): Promise { + if (await existingNonShim(linkPath)) throw new Error(`${linkPath} already exists and is not a command shim`) + await writeFile(linkPath, `@echo off\r\nnode "${targetPath}" %*\r\n`) +} + +async function existingNonShim(path: string): Promise { + try { + const stat = await lstat(path) + return !stat.isFile() + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false + throw error + } +} + async function existingNonSymlink(path: string): Promise { try { const stat = await lstat(path)