diff --git a/src/cli/cli-installer.platform.test.ts b/src/cli/cli-installer.platform.test.ts
new file mode 100644
index 000000000..84dc07a20
--- /dev/null
+++ b/src/cli/cli-installer.platform.test.ts
@@ -0,0 +1,177 @@
+///
+
+import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
+import { runCliInstaller } from "./cli-installer"
+import * as configManager from "./config-manager"
+import * as codexInstaller from "./install-codex"
+import type { CodexInstallResult } from "./install-codex"
+import type { InstallArgs } from "./types"
+
+const codexResult: CodexInstallResult = {
+ marketplaceName: "sisyphuslabs",
+ installed: [],
+ configPath: "/tmp/codex-config.toml",
+ codexHome: "/tmp/codex-home",
+}
+
+function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs {
+ return {
+ tui: false,
+ platform,
+ claude: "no",
+ openai: "no",
+ gemini: "no",
+ copilot: "no",
+ opencodeZen: "no",
+ zaiCodingPlan: "no",
+ kimiForCoding: "no",
+ opencodeGo: "no",
+ vercelAiGateway: "no",
+ }
+}
+
+function stubOpenCodeSuccess(): void {
+ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
+ isInstalled: false,
+ installedVersion: null,
+ hasClaude: false,
+ isMax20: false,
+ hasOpenAI: false,
+ hasGemini: false,
+ hasCopilot: false,
+ hasCodex: false,
+ hasOpencodeZen: false,
+ hasZaiCodingPlan: false,
+ hasKimiForCoding: false,
+ hasOpencodeGo: false,
+ hasVercelAiGateway: false,
+ })
+ spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
+ spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
+ spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
+ success: true,
+ configPath: "/tmp/opencode.jsonc",
+ })
+ spyOn(configManager, "writeOmoConfig").mockReturnValue({
+ success: true,
+ configPath: "/tmp/oh-my-opencode.jsonc",
+ })
+}
+
+describe("runCliInstaller platform branching", () => {
+ const consoleLogMock = mock(() => {})
+ const consoleLog = console.log
+ const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
+
+ beforeEach(() => {
+ consoleLogMock.mockClear()
+ console.log = consoleLogMock
+ process.env.OMO_PUBLISH_LAZYCODEX = "true"
+ })
+
+ afterEach(() => {
+ console.log = consoleLog
+ if (originalPublishLazycodex === undefined) {
+ delete process.env.OMO_PUBLISH_LAZYCODEX
+ } else {
+ process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
+ }
+ mock.restore()
+ })
+
+ test("runs only OpenCode installation for platform=opencode", async () => {
+ // given
+ stubOpenCodeSuccess()
+ const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
+ const writeSpy = spyOn(configManager, "writeOmoConfig")
+
+ // when
+ const result = await runCliInstaller(createOpenCodeArgs("opencode"), "3.4.0")
+
+ // then
+ expect(result).toBe(0)
+ expect(writeSpy).toHaveBeenCalledTimes(1)
+ expect(codexSpy).not.toHaveBeenCalled()
+ })
+
+ test("runs only Codex installation and skips OpenCode version checks for platform=codex", async () => {
+ // given
+ const versionSpy = spyOn(configManager, "getOpenCodeVersion")
+ const writeSpy = spyOn(configManager, "writeOmoConfig")
+ const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
+
+ // when
+ const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0")
+
+ // then
+ expect(result).toBe(0)
+ expect(versionSpy).not.toHaveBeenCalled()
+ expect(writeSpy).not.toHaveBeenCalled()
+ expect(codexSpy).toHaveBeenCalledTimes(1)
+ })
+
+ test("passes Codex autonomous selection into Codex installer", async () => {
+ // given
+ const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
+
+ // when
+ const result = await runCliInstaller({ tui: false, platform: "codex", codexAutonomous: true }, "3.4.0")
+
+ // then
+ expect(result).toBe(0)
+ expect(codexSpy).toHaveBeenCalledWith({ autonomousPermissions: true })
+ })
+
+ test("runs OpenCode and Codex installation for platform=both", async () => {
+ // given
+ stubOpenCodeSuccess()
+ const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
+ const writeSpy = spyOn(configManager, "writeOmoConfig")
+
+ // when
+ const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0")
+
+ // then
+ expect(result).toBe(0)
+ expect(writeSpy).toHaveBeenCalledTimes(1)
+ expect(codexSpy).toHaveBeenCalledTimes(1)
+ })
+
+ test("fails when Codex-only installation cannot install Codex", async () => {
+ // given
+ spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed"))
+
+ // when
+ const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0")
+
+ // then
+ expect(result).toBe(1)
+ })
+
+ test("keeps OpenCode success when Codex fails for platform=both", async () => {
+ // given
+ stubOpenCodeSuccess()
+ spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed"))
+
+ // when
+ const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0")
+
+ // then
+ expect(result).toBe(0)
+ })
+
+ test("prints star commands for OpenAgent and LazyCodex", async () => {
+ // given
+ stubOpenCodeSuccess()
+ spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
+
+ // when
+ const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0")
+
+ // then
+ const output = consoleLogMock.mock.calls.map((call) => call.join(" ")).join("\n")
+ expect(result).toBe(0)
+ expect(output).toContain("/user/starred/code-yeongyu/oh-my-openagent")
+ expect(output).toContain("/user/starred/code-yeongyu/lazycodex")
+ })
+})
diff --git a/src/cli/install-platform-resolution.test.ts b/src/cli/install-platform-resolution.test.ts
new file mode 100644
index 000000000..1cecf9648
--- /dev/null
+++ b/src/cli/install-platform-resolution.test.ts
@@ -0,0 +1,120 @@
+///
+
+import { describe, expect, test } from "bun:test"
+import { resolveInstallArgs } from "./cli-program"
+
+describe("install platform resolution", () => {
+ test("leaves omo install without --platform unresolved for config defaults", () => {
+ // given
+ const invocationName = "omo"
+
+ // when
+ const args = resolveInstallArgs({ tui: true }, invocationName)
+
+ // then
+ expect(args.platform).toBeUndefined()
+ })
+
+ test("resolves explicit --platform=codex", () => {
+ // given
+ const invocationName = "omo"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "codex" }, invocationName)
+
+ // then
+ expect(args.platform).toBe("codex")
+ })
+
+ test("preserves explicit Codex autonomous install flag", () => {
+ // given
+ const invocationName = "omo"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "codex", codexAutonomous: true }, invocationName)
+
+ // then
+ expect(args.codexAutonomous).toBe(true)
+ })
+
+ test("resolves explicit --platform=both", () => {
+ // given
+ const invocationName = "omo"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName)
+
+ // then
+ expect(args.platform).toBe("both")
+ })
+
+ test("resolves explicit --platform=opencode", () => {
+ // given
+ const invocationName = "omo"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName)
+
+ // then
+ expect(args.platform).toBe("opencode")
+ })
+
+ test("leaves lazycodex install unresolved when lazycodex publishing is disabled", () => {
+ // given
+ const invocationName = "lazycodex"
+
+ // when
+ const args = resolveInstallArgs({ tui: true }, invocationName)
+
+ // then
+ expect(args.platform).toBeUndefined()
+ })
+
+ test("defaults lazycodex install to codex platform when lazycodex publishing is enabled", () => {
+ // given
+ const invocationName = "lazycodex"
+
+ // when
+ const args = resolveInstallArgs({ tui: true }, invocationName, { OMO_PUBLISH_LAZYCODEX: "true" })
+
+ // then
+ expect(args.platform).toBe("codex")
+ })
+
+ test("lets lazycodex install explicitly override to both", () => {
+ // given
+ const invocationName = "lazycodex"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName)
+
+ // then
+ expect(args.platform).toBe("both")
+ })
+
+ test("lets lazycodex install explicitly override to opencode", () => {
+ // given
+ const invocationName = "lazycodex"
+
+ // when
+ const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName)
+
+ // then
+ expect(args.platform).toBe("opencode")
+ })
+
+ test("defines Commander choices so invalid --platform values are rejected", async () => {
+ // given
+ const cliProgramSource = await Bun.file(new URL("./cli-program.ts", import.meta.url)).text()
+
+ // when
+ const installBlock = cliProgramSource.match(/program\s*\n\s*\.command\("install"\)([\s\S]*?)\.action\(/)
+
+ // then
+ expect(installBlock).not.toBeNull()
+ expect(installBlock?.[1]).toContain('new Option("--platform "')
+ expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "both"])')
+ expect(installBlock?.[1]).toContain("--codex-autonomous")
+ expect(installBlock?.[1]).toContain("--no-codex-autonomous")
+ })
+})
diff --git a/src/cli/lazycodex-feature-flag.ts b/src/cli/lazycodex-feature-flag.ts
new file mode 100644
index 000000000..24545b859
--- /dev/null
+++ b/src/cli/lazycodex-feature-flag.ts
@@ -0,0 +1,15 @@
+import type { InstallPlatform } from "./types"
+
+export const LAZYCODEX_PUBLISH_FLAG = "OMO_PUBLISH_LAZYCODEX"
+export const LAZYCODEX_DISABLED_MESSAGE =
+ "Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install."
+
+type Environment = Readonly>
+
+export function isLazycodexPublishingEnabled(env: Environment = process.env): boolean {
+ return env[LAZYCODEX_PUBLISH_FLAG] === "true"
+}
+
+export function platformRequiresLazycodex(platform: InstallPlatform | undefined): boolean {
+ return platform === "codex" || platform === "both"
+}
diff --git a/src/cli/star-request.ts b/src/cli/star-request.ts
new file mode 100644
index 000000000..7926a28f5
--- /dev/null
+++ b/src/cli/star-request.ts
@@ -0,0 +1,8 @@
+export const STAR_REPOSITORIES = [
+ "code-yeongyu/oh-my-openagent",
+ "code-yeongyu/lazycodex",
+] as const
+
+export function formatGitHubStarCommand(repository: string): string {
+ return `gh api --silent --method PUT /user/starred/${repository} >/dev/null 2>&1 || true`
+}
diff --git a/src/cli/tui-install-prompts.test.ts b/src/cli/tui-install-prompts.test.ts
new file mode 100644
index 000000000..995da83fd
--- /dev/null
+++ b/src/cli/tui-install-prompts.test.ts
@@ -0,0 +1,164 @@
+///
+
+import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
+import * as p from "@clack/prompts"
+import * as prompts from "./tui-install-prompts"
+import type { DetectedConfig, InstallConfig, InstallPlatform } from "./types"
+
+function createDetectedConfig(): DetectedConfig {
+ return {
+ isInstalled: false,
+ installedVersion: null,
+ hasClaude: false,
+ isMax20: false,
+ hasOpenAI: false,
+ hasGemini: false,
+ hasCopilot: false,
+ hasCodex: false,
+ hasOpencodeZen: false,
+ hasZaiCodingPlan: false,
+ hasKimiForCoding: false,
+ hasOpencodeGo: false,
+ hasVercelAiGateway: false,
+ }
+}
+
+function withTty(): () => void {
+ const originalIsStdinTty = process.stdin.isTTY
+ const originalIsStdoutTty = process.stdout.isTTY
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
+ Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true })
+ return () => {
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
+ Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
+ }
+}
+
+describe("promptInstallPlatform", () => {
+ let restoreTty: () => void
+
+ beforeEach(() => {
+ restoreTty = withTty()
+ })
+
+ afterEach(() => {
+ restoreTty()
+ mock.restore()
+ })
+
+ test("offers OpenCode, Codex, and Both choices when lazycodex publishing is enabled", async () => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue("opencode")
+
+ // when
+ const value = await prompts.promptInstallPlatform("opencode", true)
+
+ // then
+ expect(value).toBe("opencode")
+ expect(selectSpy).toHaveBeenCalledTimes(1)
+ expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({
+ initialValue: "opencode",
+ options: [
+ { value: "opencode" },
+ { value: "codex" },
+ { value: "both" },
+ ],
+ })
+ })
+
+ test("hides Codex platform choices when lazycodex publishing is disabled", async () => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue("opencode")
+
+ // when
+ const value = await prompts.promptInstallPlatform("codex", false)
+
+ // then
+ expect(value).toBe("opencode")
+ expect(selectSpy).toHaveBeenCalledTimes(1)
+ expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({
+ initialValue: "opencode",
+ options: [{ value: "opencode" }],
+ })
+ })
+})
+
+describe("promptInstallConfig platform branching", () => {
+ let restoreTty: () => void
+
+ beforeEach(() => {
+ restoreTty = withTty()
+ })
+
+ afterEach(() => {
+ restoreTty()
+ mock.restore()
+ })
+
+ test("skips OpenCode questions when the user selects codex", async () => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue(true)
+
+ // when
+ const config = await prompts.promptInstallConfig(createDetectedConfig(), "codex")
+
+ // then
+ expect(config).toMatchObject({
+ platform: "codex",
+ hasOpenCode: false,
+ hasCodex: true,
+ codexAutonomous: true,
+ } satisfies Partial)
+ expect(selectSpy).toHaveBeenCalledTimes(1)
+ expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({
+ initialValue: true,
+ options: [{ value: true }, { value: false }],
+ })
+ })
+
+ test.each([
+ ["opencode", false],
+ ["both", true],
+ ] satisfies readonly [InstallPlatform, boolean][])(
+ "asks OpenCode questions when the user selects %s",
+ async (platform, hasCodex) => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue("no")
+
+ // when
+ const config = await prompts.promptInstallConfig(createDetectedConfig(), platform)
+
+ // then
+ expect(config).toMatchObject({ platform, hasOpenCode: true, hasCodex } satisfies Partial)
+ expect(selectSpy).toHaveBeenCalledTimes(hasCodex ? 10 : 9)
+ },
+ )
+
+ test("uses explicit Codex autonomous override without asking", async () => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue("no")
+
+ // when
+ const config = await prompts.promptInstallConfig(createDetectedConfig(), "codex", false)
+
+ // then
+ expect(config).toMatchObject({
+ platform: "codex",
+ hasCodex: true,
+ codexAutonomous: false,
+ } satisfies Partial)
+ expect(selectSpy).not.toHaveBeenCalled()
+ })
+
+ test("does not ask the old Codex adapter question", async () => {
+ // given
+ const selectSpy = spyOn(p, "select").mockResolvedValue("no")
+
+ // when
+ await prompts.promptInstallConfig(createDetectedConfig(), "both")
+
+ // then
+ const messages = selectSpy.mock.calls.map((call) => call[0].message)
+ expect(messages).not.toContain("Install Codex harness adapter into ~/.codex?")
+ })
+})