diff --git a/src/cli/install-codex/codex-config-shell-settings.test.ts b/src/cli/install-codex/codex-config-shell-settings.test.ts
new file mode 100644
index 000000000..aca736d2c
--- /dev/null
+++ b/src/cli/install-codex/codex-config-shell-settings.test.ts
@@ -0,0 +1,31 @@
+///
+///
+
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, readFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { updateCodexConfig } from "./codex-config-toml"
+
+describe("codex-config shell settings", () => {
+ test("#given Codex config update #when Git Bash is required by installer #then no fake shell config is written", async () => {
+ // given
+ const root = await mkdtemp(join(tmpdir(), "omo-codex-config-shell-"))
+ const configPath = join(root, "config.toml")
+
+ // when
+ await updateCodexConfig({
+ configPath,
+ repoRoot: "/repo/packages/omo-codex",
+ marketplaceName: "debug",
+ marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" },
+ pluginNames: ["omo"],
+ })
+
+ // then
+ const content = await readFile(configPath, "utf8")
+ expect(content).not.toContain("default_shell")
+ expect(content).not.toContain("shell_path")
+ expect(content).not.toContain("git_bash")
+ })
+})
diff --git a/src/cli/install-codex/install-codex-git-bash-preflight.test.ts b/src/cli/install-codex/install-codex-git-bash-preflight.test.ts
new file mode 100644
index 000000000..627bbbcd9
--- /dev/null
+++ b/src/cli/install-codex/install-codex-git-bash-preflight.test.ts
@@ -0,0 +1,110 @@
+///
+///
+
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { runCodexInstaller } from "./install-codex"
+import type { CommandRunOptions } from "./types"
+
+const WINDOWS_GIT_BASH_PATH = "C:\\Program Files\\Git\\bin\\bash.exe"
+
+describe("install-codex Git Bash preflight", () => {
+ test("#given Windows without Git Bash #when installing Codex profile #then rejects before marketplace or config mutation", async () => {
+ // given
+ const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-home-"))
+ const repoRoot = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-repo-"))
+ const commands: string[] = []
+
+ // when
+ const install = runCodexInstaller({
+ codexHome,
+ repoRoot,
+ platform: "win32",
+ gitBashResolver: () => ({
+ found: false,
+ checkedPaths: [WINDOWS_GIT_BASH_PATH],
+ installHint: [
+ "Git Bash is required.",
+ "winget install --id Git.Git -e --source winget",
+ "OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe",
+ "rerun `bunx omo install --platform=codex`",
+ ].join("\n"),
+ }),
+ runCommand: async (command: string, args: readonly string[], options: CommandRunOptions) => {
+ commands.push([command, ...args, options.cwd].join(" "))
+ },
+ })
+
+ // then
+ await expect(install).rejects.toThrow("winget install --id Git.Git -e --source winget")
+ expect(commands).toEqual([])
+ await expect(stat(join(codexHome, "config.toml"))).rejects.toThrow()
+ })
+
+ test("#given Windows with Git Bash #when installing Codex profile #then proceeds and reports detected path", async () => {
+ // given
+ const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-home-"))
+ const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-bin-"))
+
+ // when
+ const result = await runCodexInstaller({
+ codexHome,
+ binDir,
+ repoRoot: process.cwd(),
+ platform: "win32",
+ gitBashResolver: () => ({ found: true, path: WINDOWS_GIT_BASH_PATH, source: "program-files" }),
+ runCommand: async () => undefined,
+ })
+
+ // then
+ expect(result.gitBashPath).toBe(WINDOWS_GIT_BASH_PATH)
+ expect(await readFile(join(codexHome, "config.toml"), "utf8")).toContain("[marketplaces.sisyphuslabs]")
+ })
+
+ test("#given Windows env override in installer options #when no custom resolver is provided #then default resolver uses it", async () => {
+ // given
+ const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-home-"))
+ const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-bin-"))
+ const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-")), "bash.exe")
+ await writeFile(gitBashPath, "")
+
+ // when
+ const result = await runCodexInstaller({
+ codexHome,
+ binDir,
+ repoRoot: process.cwd(),
+ platform: "win32",
+ env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
+ runCommand: async () => undefined,
+ })
+
+ // then
+ expect(result.gitBashPath).toBe(gitBashPath)
+ })
+
+ test("#given non-Windows install #when Git Bash resolver would fail #then installer keeps existing behavior", async () => {
+ // given
+ const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-home-"))
+ const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-bin-"))
+
+ // when
+ const result = await runCodexInstaller({
+ codexHome,
+ binDir,
+ repoRoot: process.cwd(),
+ platform: "linux",
+ gitBashResolver: () => ({
+ found: false,
+ checkedPaths: [WINDOWS_GIT_BASH_PATH],
+ installHint: "should not be used",
+ }),
+ runCommand: async () => undefined,
+ })
+
+ // then
+ expect(result.gitBashPath).toBeNull()
+ expect(await readFile(join(codexHome, "config.toml"), "utf8")).toContain("[marketplaces.sisyphuslabs]")
+ })
+})
diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts
index 4ea48ef57..7d4728b7f 100644
--- a/src/cli/install-codex/install-codex.ts
+++ b/src/cli/install-codex/install-codex.ts
@@ -5,6 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
import { updateCodexConfig } from "./codex-config-toml"
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
+import { resolveGitBashForCurrentProcess } from "./git-bash"
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
import { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot"
@@ -14,12 +15,21 @@ import type { CodexInstallOptions, CodexInstallResult, CodexMarketplaceSource, I
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise {
- const repoRoot = resolve(options.repoRoot ?? findRepoRoot({ importerDir: import.meta.dir, env: process.env }))
- const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"))
- const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env })
+ const env = options.env ?? process.env
+ const platform = options.platform ?? process.platform
+ const repoRoot = resolve(options.repoRoot ?? findRepoRoot({ importerDir: import.meta.dir, env }))
+ const codexHome = resolve(options.codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex"))
+ const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env })
const runCommand = options.runCommand ?? defaultRunCommand
const log = options.log ?? (() => undefined)
+ const gitBashResolution = platform === "win32"
+ ? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))()
+ : { found: true, path: null, source: "not-required" } as const
+ if (!gitBashResolution.found) {
+ throw new Error(gitBashResolution.installHint)
+ }
+
const codexPackageRoot = join(repoRoot, "packages", "omo-codex")
const marketplace = await readMarketplace(repoRoot, {
marketplacePath: join(codexPackageRoot, "marketplace.json"),
@@ -125,6 +135,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
installed,
configPath,
codexHome,
+ gitBashPath: gitBashResolution.path,
}
}
diff --git a/src/cli/install-codex/types.ts b/src/cli/install-codex/types.ts
index 5254a2b43..c5be93be1 100644
--- a/src/cli/install-codex/types.ts
+++ b/src/cli/install-codex/types.ts
@@ -56,10 +56,29 @@ export type RunCommand = (
options: CommandRunOptions,
) => Promise
+export type CodexInstallPlatform = "aix" | "android" | "darwin" | "freebsd" | "haiku" | "linux" | "openbsd" | "sunos" | "win32" | "cygwin" | "netbsd"
+
+export type GitBashResolution =
+ | {
+ readonly found: true
+ readonly path: string | null
+ readonly source: "not-required" | "env" | "program-files" | "program-files-x86" | "path"
+ }
+ | {
+ readonly found: false
+ readonly checkedPaths: readonly string[]
+ readonly installHint: string
+ }
+
+export type GitBashResolver = () => GitBashResolution
+
export interface CodexInstallOptions {
readonly codexHome?: string
readonly binDir?: string
readonly repoRoot?: string
+ readonly platform?: CodexInstallPlatform
+ readonly env?: { readonly [key: string]: string | undefined }
+ readonly gitBashResolver?: GitBashResolver
readonly autonomousPermissions?: boolean
readonly runCommand?: RunCommand
readonly log?: (message: string) => void
@@ -70,4 +89,5 @@ export interface CodexInstallResult {
readonly installed: readonly InstalledPlugin[]
readonly configPath: string
readonly codexHome: string
+ readonly gitBashPath: string | null
}