test(cli): batch 82 (10 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:14 +09:00
parent ac0df8a08f
commit 7c9b70fd8e
10 changed files with 695 additions and 114 deletions
+1
View File
@@ -18,6 +18,7 @@ describe("runCliInstaller telemetry isolation", () => {
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
+51
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types"
@@ -8,6 +9,7 @@ describe("runCliInstaller", () => {
const mockConsoleError = mock(() => {})
const originalConsoleLog = console.log
const originalConsoleError = console.error
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => {
console.log = mockConsoleLog
@@ -19,6 +21,11 @@ describe("runCliInstaller", () => {
afterEach(() => {
console.log = originalConsoleLog
console.error = originalConsoleError
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
mock.restore()
})
@@ -33,6 +40,7 @@ describe("runCliInstaller", () => {
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
@@ -46,6 +54,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = {
tui: false,
platform: "opencode",
claude: "no",
openai: "no",
gemini: "no",
@@ -80,6 +89,7 @@ describe("runCliInstaller", () => {
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
@@ -100,6 +110,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = {
tui: false,
platform: "opencode",
claude: "no",
openai: "yes",
gemini: "no",
@@ -120,4 +131,44 @@ describe("runCliInstaller", () => {
spy.mockRestore()
}
})
it("skips OpenCode checks and writes for platform=codex", async () => {
// given
process.env.OMO_PUBLISH_LAZYCODEX = "true"
const detectSpy = spyOn(configManager, "detectCurrentConfig")
const installedSpy = spyOn(configManager, "isOpenCodeInstalled")
const versionSpy = spyOn(configManager, "getOpenCodeVersion")
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
const writeConfigSpy = spyOn(configManager, "writeOmoConfig")
const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue({
installed: [],
configPath: "/tmp/codex-config.toml",
codexHome: "/tmp/codex-home",
marketplaceName: "sisyphuslabs",
})
const args: InstallArgs = {
tui: false,
platform: "codex",
}
// when
const result = await runCliInstaller(args, "3.4.0")
// then
expect(result).toBe(0)
expect(detectSpy).not.toHaveBeenCalled()
expect(installedSpy).not.toHaveBeenCalled()
expect(versionSpy).not.toHaveBeenCalled()
expect(addPluginSpy).not.toHaveBeenCalled()
expect(writeConfigSpy).not.toHaveBeenCalled()
detectSpy.mockRestore()
installedSpy.mockRestore()
versionSpy.mockRestore()
addPluginSpy.mockRestore()
writeConfigSpy.mockRestore()
codexSpy.mockRestore()
})
})
+79 -39
View File
@@ -23,6 +23,8 @@ import {
validateNonTuiArgs,
} from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { runCodexInstaller } from "./install-codex"
import { STAR_REPOSITORIES, formatGitHubStarCommand } from "./star-request"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args)
@@ -40,29 +42,49 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
const config = argsToConfig(args)
const hasOpenCode = config.hasOpenCode
const detected = hasOpenCode
? detectCurrentConfig()
: {
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,
}
const isUpdate = hasOpenCode && detected.isInstalled
printHeader(isUpdate)
const totalSteps = 4
const totalSteps = hasOpenCode ? 4 : 2
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
printWarning(
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.",
)
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
if (hasOpenCode) {
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
printWarning(
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.",
)
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage)
return 1
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage)
return 1
}
}
}
@@ -71,25 +93,25 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
if (hasOpenCode) {
printStep(step++, totalSteps, `Adding ${PLUGIN_NAME} plugin...`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
printStep(step++, totalSteps, `Adding ${PLUGIN_NAME} plugin...`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
}
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
@@ -112,9 +134,27 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
if (hasOpenCode) {
console.log(` Run ${color.cyan("opencode")} to start!`)
}
console.log()
if (config.hasCodex) {
printInfo("Installing Codex harness adapter...")
try {
const codexResult = await runCodexInstaller({ autonomousPermissions: config.codexAutonomous })
printSuccess(`Codex plugin installed ${SYMBOLS.arrow} ${color.dim(codexResult.configPath)}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!config.hasOpenCode) {
printError(`Codex install failed: ${message}`)
return 1
}
printWarning(`Codex install failed (OpenCode install is still complete): ${message}`)
}
console.log()
}
printInfo(
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
)
@@ -129,14 +169,14 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`,
)
for (const repository of STAR_REPOSITORIES) {
console.log(` ${color.dim(formatGitHubStarCommand(repository))}`)
}
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
if (hasOpenCode && (config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
+52 -16
View File
@@ -1,4 +1,4 @@
import { Command } from "commander"
import { Command, Option } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
@@ -6,6 +6,7 @@ import { doctor } from "./doctor"
import { refreshModelCapabilities } from "./refresh-model-capabilities"
import { createMcpOAuthCommand } from "./mcp-oauth"
import { boulder } from "./boulder"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
@@ -16,6 +17,48 @@ const VERSION = packageJson.version
const program = new Command()
type InstallCommandOptions = {
readonly tui?: boolean
readonly claude?: InstallArgs["claude"]
readonly openai?: InstallArgs["openai"]
readonly gemini?: InstallArgs["gemini"]
readonly copilot?: InstallArgs["copilot"]
readonly platform?: InstallArgs["platform"]
readonly opencodeZen?: InstallArgs["opencodeZen"]
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
readonly opencodeGo?: InstallArgs["opencodeGo"]
readonly vercelAiGateway?: InstallArgs["vercelAiGateway"]
readonly codexAutonomous?: InstallArgs["codexAutonomous"]
readonly skipAuth?: boolean
}
type Environment = Readonly<Record<string, string | undefined>>
export function resolveInstallArgs(
options: InstallCommandOptions,
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
env: Environment = process.env,
): InstallArgs {
const defaultPlatform = invocationName === "lazycodex" && isLazycodexPublishingEnabled(env) ? "codex" : undefined
return {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
platform: options.platform ?? defaultPlatform,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
opencodeGo: options.opencodeGo,
vercelAiGateway: options.vercelAiGateway,
codexAutonomous: options.codexAutonomous,
skipAuth: options.skipAuth ?? false,
}
}
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
@@ -32,16 +75,21 @@ program
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.addOption(new Option("--platform <platform>", "Install target platform: opencode, codex, both").choices(["opencode", "codex", "both"]))
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--opencode-go <value>", "OpenCode Go subscription: no, yes (default: no)")
.option("--vercel-ai-gateway <value>", "Vercel AI Gateway: no, yes (default: no)")
.option("--codex-autonomous", "Configure Codex with approval never, full filesystem access, and network enabled")
.option("--no-codex-autonomous", "Leave existing Codex permission settings unchanged")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx lazycodex install --no-tui
$ bunx oh-my-opencode install --no-tui --platform=both --claude=max20 --openai=yes --gemini=yes --copilot=no
$ omo install --platform=codex --codex-autonomous
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
@@ -55,19 +103,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Verce
Vercel vercel/ models (universal proxy, always last fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
opencodeGo: options.opencodeGo,
vercelAiGateway: options.vercelAiGateway,
skipAuth: options.skipAuth ?? false,
}
const args = resolveInstallArgs(options)
const exitCode = await install(args)
process.exit(exitCode)
})
+165 -1
View File
@@ -1,6 +1,8 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { validateNonTuiArgs } from "./install-validators"
import { argsToConfig, formatConfigSummary, validateNonTuiArgs } from "./install-validators"
import type { InstallArgs } from "./types"
function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
@@ -14,11 +16,79 @@ function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
vercelAiGateway: "no",
skipAuth: false,
...overrides,
}
}
describe("argsToConfig", () => {
test("enables only OpenCode when platform is opencode", () => {
// #given
const args = createArgs({ platform: "opencode" })
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("opencode")
expect(config.hasOpenCode).toBe(true)
expect(config.hasCodex).toBe(false)
})
test("enables only Codex when platform is codex", () => {
// #given
const args = createArgs({ platform: "codex", codexAutonomous: true })
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("codex")
expect(config.hasOpenCode).toBe(false)
expect(config.hasCodex).toBe(true)
expect(config.codexAutonomous).toBe(true)
})
test("ignores Codex autonomous mode when Codex is not installed", () => {
// #given
const args = createArgs({ platform: "opencode", codexAutonomous: true })
// #when
const config = argsToConfig(args)
// #then
expect(config.hasCodex).toBe(false)
expect(config.codexAutonomous).toBe(false)
})
test("enables both harnesses when platform is both", () => {
// #given
const args = createArgs({ platform: "both" })
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("both")
expect(config.hasOpenCode).toBe(true)
expect(config.hasCodex).toBe(true)
})
test("defaults to OpenCode when platform is omitted", () => {
// #given
const args = createArgs()
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("opencode")
expect(config.hasOpenCode).toBe(true)
expect(config.hasCodex).toBe(false)
})
})
describe("validateNonTuiArgs", () => {
test("rejects invalid --opencode-go values", () => {
// #given
@@ -31,4 +101,98 @@ describe("validateNonTuiArgs", () => {
expect(result.valid).toBe(false)
expect(result.errors).toContain("Invalid --opencode-go value: maybe (expected: no, yes)")
})
test("requires OpenCode provider flags when platform is opencode", () => {
// #given
const args = createArgs({ platform: "opencode", claude: undefined, gemini: undefined, copilot: undefined })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain("--claude is required (values: no, yes, max20)")
expect(result.errors).toContain("--gemini is required (values: no, yes)")
expect(result.errors).toContain("--copilot is required (values: no, yes)")
})
test("requires OpenCode provider flags when platform is both", () => {
// #given
const args = createArgs({ platform: "both", claude: undefined, gemini: undefined, copilot: undefined })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain("--claude is required (values: no, yes, max20)")
expect(result.errors).toContain("--gemini is required (values: no, yes)")
expect(result.errors).toContain("--copilot is required (values: no, yes)")
})
test("rejects codex-only non-TUI installs when lazycodex publishing is disabled", () => {
// #given
const args: InstallArgs = { tui: false, platform: "codex" }
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain(
"Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.",
)
})
test("allows codex-only non-TUI installs with lazycodex publishing enabled", () => {
// #given
const args: InstallArgs = { tui: false, platform: "codex" }
// #when
const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" })
// #then
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
test("rejects platform=both when lazycodex publishing is disabled", () => {
// #given
const args = createArgs({ platform: "both" })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain(
"Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.",
)
})
test("rejects OpenCode flags for codex-only non-TUI installs", () => {
// #given
const args = createArgs({ platform: "codex", claude: "yes" })
// #when
const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" })
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain("--claude cannot be used with --platform=codex")
})
})
describe("formatConfigSummary", () => {
test("shows platform instead of a separate Codex Harness provider line", () => {
// #given
const config = argsToConfig(createArgs({ platform: "both" }))
// #when
const summary = formatConfigSummary(config)
// #then
expect(summary).toContain("Platform: both")
expect(summary).not.toContain("Codex Harness")
})
})
+71 -18
View File
@@ -5,7 +5,13 @@ import type {
DetectedConfig,
InstallArgs,
InstallConfig,
InstallPlatform,
} from "./types"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
platformRequiresLazycodex,
} from "./lazycodex-feature-flag"
export const SYMBOLS = {
check: color.green("[OK]"),
@@ -18,6 +24,7 @@ export const SYMBOLS = {
}
const ANSI_COLOR_PATTERN = new RegExp("\u001b\\[[0-9;]*m", "g")
type Environment = Readonly<Record<string, string | undefined>>
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
@@ -31,6 +38,11 @@ export function formatConfigSummary(config: InstallConfig): string {
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
lines.push(` ${SYMBOLS.info} Platform: ${config.platform}`)
if (config.hasCodex) {
lines.push(` ${SYMBOLS.info} Codex autonomous mode: ${config.codexAutonomous ? "enabled" : "disabled"}`)
}
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
@@ -113,24 +125,34 @@ export function printBox(content: string, title?: string): void {
console.log()
}
export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
export function validateNonTuiArgs(
args: InstallArgs,
env: Environment = process.env,
): { valid: boolean; errors: string[] } {
const errors: string[] = []
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodexOnly = platform === "codex"
if (args.claude === undefined) {
if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) {
errors.push(LAZYCODEX_DISABLED_MESSAGE)
}
if (hasOpenCode && args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
} else if (args.claude !== undefined && !["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.gemini === undefined) {
if (hasOpenCode && args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
} else if (args.gemini !== undefined && !["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
if (args.copilot === undefined) {
if (hasOpenCode && args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) {
} else if (args.copilot !== undefined && !["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
}
@@ -158,21 +180,52 @@ export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors:
errors.push(`Invalid --vercel-ai-gateway value: ${args.vercelAiGateway} (expected: no, yes)`)
}
if (hasCodexOnly) {
const opencodeFlagErrors = collectCodexOnlyOpenCodeFlagErrors(args)
errors.push(...opencodeFlagErrors)
}
return { valid: errors.length === 0, errors }
}
function resolvePlatform(args: InstallArgs): InstallPlatform {
return args.platform ?? "opencode"
}
function collectCodexOnlyOpenCodeFlagErrors(args: InstallArgs): string[] {
const errors: string[] = []
if (args.claude !== undefined) errors.push("--claude cannot be used with --platform=codex")
if (args.openai !== undefined) errors.push("--openai cannot be used with --platform=codex")
if (args.gemini !== undefined) errors.push("--gemini cannot be used with --platform=codex")
if (args.copilot !== undefined) errors.push("--copilot cannot be used with --platform=codex")
if (args.opencodeZen !== undefined) errors.push("--opencode-zen cannot be used with --platform=codex")
if (args.zaiCodingPlan !== undefined) errors.push("--zai-coding-plan cannot be used with --platform=codex")
if (args.kimiForCoding !== undefined) errors.push("--kimi-for-coding cannot be used with --platform=codex")
if (args.opencodeGo !== undefined) errors.push("--opencode-go cannot be used with --platform=codex")
if (args.vercelAiGateway !== undefined) errors.push("--vercel-ai-gateway cannot be used with --platform=codex")
return errors
}
export function argsToConfig(args: InstallArgs): InstallConfig {
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
return {
hasClaude: args.claude !== "no",
platform,
hasOpenCode,
hasClaude: hasOpenCode && args.claude !== "no",
isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes",
hasGemini: args.gemini === "yes",
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
hasOpencodeGo: args.opencodeGo === "yes",
hasVercelAiGateway: args.vercelAiGateway === "yes",
hasOpenAI: hasOpenCode && args.openai === "yes",
hasGemini: hasOpenCode && args.gemini === "yes",
hasCopilot: hasOpenCode && args.copilot === "yes",
hasCodex,
hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
hasOpencodeGo: hasOpenCode && args.opencodeGo === "yes",
hasVercelAiGateway: hasOpenCode && args.vercelAiGateway === "yes",
codexAutonomous: hasCodex && args.codexAutonomous === true,
}
}
@@ -183,7 +236,7 @@ export function detectedToInitialValues(detected: DetectedConfig): {
copilot: BooleanArg
opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg
kimiForCoding: BooleanArg
opencodeGo: BooleanArg
vercelAiGateway: BooleanArg
} {
@@ -199,7 +252,7 @@ kimiForCoding: BooleanArg
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
}
+75 -1
View File
@@ -4,8 +4,10 @@ import type {
ClaudeSubscription,
DetectedConfig,
InstallConfig,
InstallPlatform,
} from "./types"
import { detectedToInitialValues } from "./install-validators"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: {
message: string
@@ -26,7 +28,58 @@ async function selectOrCancel<TValue extends Readonly<string | boolean | number>
return value as TValue
}
export async function promptInstallConfig(detected: DetectedConfig): Promise<InstallConfig | null> {
export async function promptInstallPlatform(
initialValue: InstallPlatform = "opencode",
lazycodexEnabled = isLazycodexPublishingEnabled(),
): Promise<InstallPlatform | null> {
const options: Option<InstallPlatform>[] = [
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
]
if (lazycodexEnabled) {
options.push(
{ value: "codex", label: "Codex", hint: "Install Codex harness adapter only" },
{ value: "both", label: "Both", hint: "Install OpenCode plugin and Codex adapter" },
)
}
const safeInitialValue = lazycodexEnabled || initialValue === "opencode" ? initialValue : "opencode"
return selectOrCancel<InstallPlatform>({
message: "Which platform do you want to install?",
options,
initialValue: safeInitialValue,
})
}
export async function promptInstallConfig(
detected: DetectedConfig,
platform: InstallPlatform,
codexAutonomousOverride?: boolean,
): Promise<InstallConfig | null> {
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
const codexAutonomous = await resolveCodexAutonomous(hasCodex, codexAutonomousOverride)
if (codexAutonomous === null) return null
if (!hasOpenCode) {
return {
platform,
hasOpenCode: false,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
codexAutonomous,
}
}
const initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({
@@ -121,15 +174,36 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
if (!vercelAiGateway) return null
return {
platform,
hasOpenCode: true,
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasCodex,
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
hasOpencodeGo: opencodeGo === "yes",
hasVercelAiGateway: vercelAiGateway === "yes",
codexAutonomous,
}
}
async function resolveCodexAutonomous(
hasCodex: boolean,
override: boolean | undefined,
): Promise<boolean | null> {
if (!hasCodex) return false
if (override !== undefined) return override
return selectOrCancel<boolean>({
message: "Configure Codex for autonomous full-permissions mode?",
options: [
{ value: true, label: "Yes", hint: "Recommended: approval never, danger-full-access, network enabled" },
{ value: false, label: "No", hint: "Leave existing Codex permissions unchanged" },
],
initialValue: true,
})
}
+99
View File
@@ -9,12 +9,17 @@ function createMockSpinner(): ReturnType<typeof p.spinner> {
start: () => undefined,
stop: () => undefined,
message: () => undefined,
cancel: () => undefined,
error: () => undefined,
clear: () => undefined,
isCancelled: false,
}
}
describe("runTuiInstaller", () => {
const originalIsStdinTty = process.stdin.isTTY
const originalIsStdoutTty = process.stdout.isTTY
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
@@ -24,6 +29,11 @@ describe("runTuiInstaller", () => {
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
})
it("blocks installation when OpenCode is below the minimum version", async () => {
@@ -32,6 +42,7 @@ describe("runTuiInstaller", () => {
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
@@ -40,11 +51,13 @@ describe("runTuiInstaller", () => {
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
codexAutonomous: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
@@ -70,6 +83,29 @@ describe("runTuiInstaller", () => {
outroSpy.mockRestore()
})
it("blocks codex platform when lazycodex publishing is disabled", async () => {
// given
delete process.env.OMO_PUBLISH_LAZYCODEX
const platformSpy = spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("codex")
const promptConfigSpy = spyOn(tuiInstallPrompts, "promptInstallConfig")
const logErrorSpy = spyOn(p.log, "error").mockImplementation(() => undefined)
const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined)
// when
const result = await runTuiInstaller({ tui: true, platform: "codex" }, "3.16.0")
// then
expect(result).toBe(1)
expect(platformSpy).toHaveBeenCalled()
expect(promptConfigSpy).not.toHaveBeenCalled()
expect(logErrorSpy).toHaveBeenCalled()
platformSpy.mockRestore()
promptConfigSpy.mockRestore()
logErrorSpy.mockRestore()
outroSpy.mockRestore()
})
it("proceeds when OpenCode meets the minimum version", async () => {
// given
const restoreSpies = [
@@ -81,6 +117,7 @@ describe("runTuiInstaller", () => {
spyOn(p.log, "message").mockImplementation(() => undefined),
spyOn(p, "note").mockImplementation(() => undefined),
spyOn(p, "outro").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
@@ -89,25 +126,31 @@ describe("runTuiInstaller", () => {
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
codexAutonomous: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
platform: "opencode",
hasOpenCode: true,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
codexAutonomous: false,
}),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
@@ -129,4 +172,60 @@ describe("runTuiInstaller", () => {
spy.mockRestore()
}
})
it("skips OpenCode checks and writes when platform is codex", async () => {
// given
process.env.OMO_PUBLISH_LAZYCODEX = "true"
const restoreSpies = [
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "info").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(p.log, "success").mockImplementation(() => undefined),
spyOn(p.log, "message").mockImplementation(() => undefined),
spyOn(p, "note").mockImplementation(() => undefined),
spyOn(p, "outro").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("codex"),
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
platform: "codex",
hasOpenCode: false,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}),
]
const detectConfigSpy = spyOn(configManager, "detectCurrentConfig")
const isInstalledSpy = spyOn(configManager, "isOpenCodeInstalled")
const getVersionSpy = spyOn(configManager, "getOpenCodeVersion")
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
const writeConfigSpy = spyOn(configManager, "writeOmoConfig")
// when
const result = await runTuiInstaller({ tui: true, platform: "codex" }, "3.16.0")
// then
expect(result).toBe(0)
expect(detectConfigSpy).not.toHaveBeenCalled()
expect(isInstalledSpy).not.toHaveBeenCalled()
expect(getVersionSpy).not.toHaveBeenCalled()
expect(addPluginSpy).not.toHaveBeenCalled()
expect(writeConfigSpy).not.toHaveBeenCalled()
for (const spy of restoreSpies) {
spy.mockRestore()
}
detectConfigSpy.mockRestore()
isInstalledSpy.mockRestore()
getVersionSpy.mockRestore()
addPluginSpy.mockRestore()
writeConfigSpy.mockRestore()
})
})
+93 -38
View File
@@ -11,7 +11,14 @@ import {
} from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig } from "./tui-install-prompts"
import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
import { runCodexInstaller } from "./install-codex"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
platformRequiresLazycodex,
} from "./lazycodex-feature-flag"
import { STAR_REPOSITORIES, formatGitHubStarCommand } from "./star-request"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -19,8 +26,33 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
const selectedPlatform = await promptInstallPlatform(args.platform ?? "opencode")
if (!selectedPlatform) return 1
if (platformRequiresLazycodex(selectedPlatform) && !isLazycodexPublishingEnabled()) {
p.log.error(LAZYCODEX_DISABLED_MESSAGE)
p.outro(color.red("Installation blocked."))
return 1
}
const hasOpenCode = selectedPlatform === "opencode" || selectedPlatform === "both"
const detected = hasOpenCode
? detectCurrentConfig()
: {
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,
}
const isUpdate = hasOpenCode && detected.isInstalled
p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... ")))
@@ -30,45 +62,49 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
}
const spinner = p.spinner()
spinner.start("Checking OpenCode installation")
if (hasOpenCode) {
spinner.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
p.log.warn(unsupportedVersionMessage)
p.outro(color.red("Installation blocked."))
return 1
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
p.log.warn(unsupportedVersionMessage)
p.outro(color.red("Installation blocked."))
return 1
}
}
}
const config = await promptInstallConfig(detected)
const config = await promptInstallConfig(detected, selectedPlatform, args.codexAutonomous)
if (!config) return 1
spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
if (config.hasOpenCode) {
spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
spinner.start(`Writing ${PLUGIN_NAME} configuration`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
spinner.start(`Writing ${PLUGIN_NAME} configuration`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
}
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) {
p.log.info(
@@ -83,8 +119,27 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (config.hasCodex) {
spinner.start("Installing Codex harness adapter")
try {
const codexResult = await runCodexInstaller({ autonomousPermissions: config.codexAutonomous })
spinner.stop(`Codex plugin installed to ${color.cyan(codexResult.configPath)}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
spinner.stop(`Codex install failed ${color.yellow("[!]")}`)
if (!config.hasOpenCode) {
p.log.error(`Codex install failed: ${message}`)
p.outro(color.red("Installation failed."))
return 1
}
p.log.warn(`Codex install failed (OpenCode install remains successful): ${message}`)
}
}
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
if (config.hasOpenCode) {
p.log.message(`Run ${color.cyan("opencode")} to start!`)
}
p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.")
p.log.info("Docs: docs/legal/privacy-policy.md and docs/legal/terms-of-service.md")
@@ -96,13 +151,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`,
)
for (const repository of STAR_REPOSITORIES) {
p.log.message(` ${color.dim(formatGitHubStarCommand(repository))}`)
}
p.outro(color.green("oMoMoMoMo... Enjoy!"))
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
if (config.hasOpenCode && (config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ Gemini")}`)
+9 -1
View File
@@ -1,31 +1,38 @@
export type ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes"
export type InstallPlatform = "opencode" | "codex" | "both"
export interface InstallArgs {
tui: boolean
platform?: InstallPlatform
claude?: ClaudeSubscription
openai?: BooleanArg
gemini?: BooleanArg
copilot?: BooleanArg
opencodeZen?: BooleanArg
zaiCodingPlan?: BooleanArg
kimiForCoding?: BooleanArg
kimiForCoding?: BooleanArg
opencodeGo?: BooleanArg
vercelAiGateway?: BooleanArg
codexAutonomous?: boolean
skipAuth?: boolean
}
export interface InstallConfig {
platform: InstallPlatform
hasOpenCode: boolean
hasClaude: boolean
isMax20: boolean
hasOpenAI: boolean
hasGemini: boolean
hasCopilot: boolean
hasCodex: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
hasOpencodeGo: boolean
hasVercelAiGateway: boolean
codexAutonomous: boolean
}
export interface ConfigMergeResult {
@@ -42,6 +49,7 @@ export interface DetectedConfig {
hasOpenAI: boolean
hasGemini: boolean
hasCopilot: boolean
hasCodex: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean