diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs
index a00fa3d61..0065d5a7e 100644
--- a/packages/omo-codex/scripts/install-local.mjs
+++ b/packages/omo-codex/scripts/install-local.mjs
@@ -18,6 +18,7 @@ export async function installMarketplaceLocally(options = {}) {
const repoRoot = resolve(options.repoRoot ?? process.cwd());
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin"));
+ const platform = options.platform ?? process.platform;
const runCommand = options.runCommand ?? defaultRunCommand;
const log = options.log ?? console.log;
const marketplace = await readMarketplace(repoRoot);
@@ -43,7 +44,7 @@ export async function installMarketplaceLocally(options = {}) {
sourcePath,
version,
});
- const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path });
+ const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path, platform });
for (const link of binLinks) {
log(`Linked ${link.name} -> ${link.target}`);
}
diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs
index 3f3df42a1..88f55d0bb 100644
--- a/packages/omo-codex/scripts/install-local.test.mjs
+++ b/packages/omo-codex/scripts/install-local.test.mjs
@@ -100,6 +100,7 @@ test("#given local marketplace #when installing #then copies versioned plugins a
repoRoot,
codexHome,
binDir,
+ platform: "linux",
runCommand: async (command, args, options) => {
commands.push([command, args, options.cwd]);
},
@@ -232,3 +233,27 @@ test("#given Windows platform #when linking cached plugin bins #then writes comm
assert.match(shim, /@echo off/);
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
});
+
+test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => {
+ const root = await makeTempDir();
+ const pluginRoot = join(root, "plugin");
+ const binDir = join(root, "bin");
+
+ await mkdir(pluginRoot, { recursive: true });
+ await mkdir(binDir, { recursive: true });
+ await writeJson(join(pluginRoot, "package.json"), {
+ name: "@example/alpha",
+ bin: {
+ alpha: "./dist/cli.js",
+ },
+ });
+ await mkdir(join(pluginRoot, "dist"), { recursive: true });
+ await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
+ await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n");
+
+ await assert.rejects(
+ linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }),
+ /already exists and is not a generated command shim/,
+ );
+ assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/);
+});
diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs
index 210acfe53..6d795e060 100644
--- a/packages/omo-codex/scripts/install/cache.mjs
+++ b/packages/omo-codex/scripts/install/cache.mjs
@@ -3,6 +3,8 @@ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, wri
import { exists, isRecord } from "./utils.mjs";
+const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer";
+
export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) {
await maybeRunNpmInstall(sourcePath, runCommand);
await maybeRunNpmBuild(sourcePath, runCommand);
@@ -120,13 +122,16 @@ async function replaceCommandShim(linkPath, targetPath) {
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`);
+ await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`);
}
async function existingNonShim(path) {
try {
const stat = await lstat(path);
- return !stat.isFile();
+ if (!stat.isFile()) return true;
+ const content = await readFile(path, "utf8");
+ if (content.includes(COMMAND_SHIM_MARKER)) return false;
+ throw new Error(`${path} already exists and is not a generated command shim`);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
throw error;
diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts
index 7ff1e11f8..66efe2f3d 100644
--- a/src/cli/install-codex/codex-cache.test.ts
+++ b/src/cli/install-codex/codex-cache.test.ts
@@ -1,3 +1,6 @@
+///
+///
+
import { describe, expect, test } from "bun:test"
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
@@ -35,8 +38,8 @@ describe("codex-cache", () => {
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n")
// when
- const first = await linkCachedPluginBins({ binDir, pluginRoot })
- const second = await linkCachedPluginBins({ binDir, pluginRoot })
+ const first = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" })
+ const second = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" })
// then
expect(first).toHaveLength(1)
@@ -64,4 +67,29 @@ describe("codex-cache", () => {
expect(commandShim).toContain("@echo off")
expect(commandShim).toContain(`node "${join(pluginRoot, "dist", "cli.js")}" %*`)
})
+
+ test("rejects existing non-generated Windows command shims", 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 mkdir(binDir, { 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")
+ await writeFile(join(binDir, "omo-hook.cmd"), "@echo off\r\necho custom\r\n")
+
+ // when
+ let rejected = false
+ try {
+ await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" })
+ } catch (error) {
+ rejected = error instanceof Error && error.message.includes("already exists and is not a generated command shim")
+ }
+
+ // then
+ expect(rejected).toBe(true)
+ expect(await readFile(join(binDir, "omo-hook.cmd"), "utf8")).toContain("echo custom")
+ })
})
diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts
index 082b20606..ce35bf840 100644
--- a/src/cli/install-codex/codex-cache.ts
+++ b/src/cli/install-codex/codex-cache.ts
@@ -4,6 +4,8 @@ import type { InstalledPlugin, RunCommand } from "./types"
type LinkPlatform = NodeJS.Platform
+const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer"
+
export async function installCachedPlugin(input: {
readonly codexHome: string
readonly marketplaceName: string
@@ -163,13 +165,16 @@ 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`)
+ await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`)
}
async function existingNonShim(path: string): Promise {
try {
const stat = await lstat(path)
- return !stat.isFile()
+ if (!stat.isFile()) return true
+ const content = await readFile(path, "utf8")
+ if (content.includes(COMMAND_SHIM_MARKER)) return false
+ throw new Error(`${path} already exists and is not a generated command shim`)
} catch (error) {
if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false
throw error