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, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
+51
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager" import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import { runCliInstaller } from "./cli-installer" import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
@@ -8,6 +9,7 @@ describe("runCliInstaller", () => {
const mockConsoleError = mock(() => {}) const mockConsoleError = mock(() => {})
const originalConsoleLog = console.log const originalConsoleLog = console.log
const originalConsoleError = console.error const originalConsoleError = console.error
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => { beforeEach(() => {
console.log = mockConsoleLog console.log = mockConsoleLog
@@ -19,6 +21,11 @@ describe("runCliInstaller", () => {
afterEach(() => { afterEach(() => {
console.log = originalConsoleLog console.log = originalConsoleLog
console.error = originalConsoleError console.error = originalConsoleError
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
mock.restore() mock.restore()
}) })
@@ -33,6 +40,7 @@ describe("runCliInstaller", () => {
hasOpenAI: false, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
@@ -46,6 +54,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
platform: "opencode",
claude: "no", claude: "no",
openai: "no", openai: "no",
gemini: "no", gemini: "no",
@@ -80,6 +89,7 @@ describe("runCliInstaller", () => {
hasOpenAI: false, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
@@ -100,6 +110,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
platform: "opencode",
claude: "no", claude: "no",
openai: "yes", openai: "yes",
gemini: "no", gemini: "no",
@@ -120,4 +131,44 @@ describe("runCliInstaller", () => {
spy.mockRestore() 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, validateNonTuiArgs,
} from "./install-validators" } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" 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> { export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args) const validation = validateNonTuiArgs(args)
@@ -40,29 +42,49 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
return 1 return 1
} }
const detected = detectCurrentConfig() const config = argsToConfig(args)
const isUpdate = detected.isInstalled 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) printHeader(isUpdate)
const totalSteps = 4 const totalSteps = hasOpenCode ? 4 : 2
let step = 1 let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...") if (hasOpenCode) {
const installed = await isOpenCodeInstalled() printStep(step++, totalSteps, "Checking OpenCode installation...")
const openCodeVersion = await getOpenCodeVersion() const installed = await isOpenCodeInstalled()
if (!installed) { const openCodeVersion = await getOpenCodeVersion()
printWarning( if (!installed) {
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.", 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 { printInfo("Visit https://opencode.ai/docs for installation instructions")
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`) } else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) { if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage) printWarning(unsupportedVersionMessage)
return 1 return 1
}
} }
} }
@@ -71,25 +93,25 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`) 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...`) printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const pluginResult = await addPluginToOpenCodeConfig(version) const omoResult = writeOmoConfig(config)
if (!pluginResult.success) { if (!omoResult.success) {
printError(`Failed: ${pluginResult.error}`) printError(`Failed: ${omoResult.error}`)
return 1 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") 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(`${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() 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( printInfo(
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.", "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(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log( for (const repository of STAR_REPOSITORIES) {
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`, console.log(` ${color.dim(formatGitHubStarCommand(repository))}`)
) }
console.log() console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!")) console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log() console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { if (hasOpenCode && (config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox( printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` + `Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\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 { install } from "./install"
import { run } from "./run" import { run } from "./run"
import { getLocalVersion } from "./get-local-version" import { getLocalVersion } from "./get-local-version"
@@ -6,6 +6,7 @@ import { doctor } from "./doctor"
import { refreshModelCapabilities } from "./refresh-model-capabilities" import { refreshModelCapabilities } from "./refresh-model-capabilities"
import { createMcpOAuthCommand } from "./mcp-oauth" import { createMcpOAuthCommand } from "./mcp-oauth"
import { boulder } from "./boulder" import { boulder } from "./boulder"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
import type { RunOptions } from "./run" import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types" import type { GetLocalVersionOptions } from "./get-local-version/types"
@@ -16,6 +17,48 @@ const VERSION = packageJson.version
const program = new Command() 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 program
.name("oh-my-opencode") .name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") .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("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes") .option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: 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("--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("--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("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--opencode-go <value>", "OpenCode Go 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("--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") .option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", ` .addHelpText("after", `
Examples: Examples:
$ bunx oh-my-opencode install $ 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 $ 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): 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) Vercel vercel/ models (universal proxy, always last fallback)
`) `)
.action(async (options) => { .action(async (options) => {
const args: InstallArgs = { const args = resolveInstallArgs(options)
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 exitCode = await install(args) const exitCode = await install(args)
process.exit(exitCode) process.exit(exitCode)
}) })
+165 -1
View File
@@ -1,6 +1,8 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { validateNonTuiArgs } from "./install-validators" import { argsToConfig, formatConfigSummary, validateNonTuiArgs } from "./install-validators"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs { function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
@@ -14,11 +16,79 @@ function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
zaiCodingPlan: "no", zaiCodingPlan: "no",
kimiForCoding: "no", kimiForCoding: "no",
opencodeGo: "no", opencodeGo: "no",
vercelAiGateway: "no",
skipAuth: false, skipAuth: false,
...overrides, ...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", () => { describe("validateNonTuiArgs", () => {
test("rejects invalid --opencode-go values", () => { test("rejects invalid --opencode-go values", () => {
// #given // #given
@@ -31,4 +101,98 @@ describe("validateNonTuiArgs", () => {
expect(result.valid).toBe(false) expect(result.valid).toBe(false)
expect(result.errors).toContain("Invalid --opencode-go value: maybe (expected: no, yes)") 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, DetectedConfig,
InstallArgs, InstallArgs,
InstallConfig, InstallConfig,
InstallPlatform,
} from "./types" } from "./types"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
platformRequiresLazycodex,
} from "./lazycodex-feature-flag"
export const SYMBOLS = { export const SYMBOLS = {
check: color.green("[OK]"), check: color.green("[OK]"),
@@ -18,6 +24,7 @@ export const SYMBOLS = {
} }
const ANSI_COLOR_PATTERN = new RegExp("\u001b\\[[0-9;]*m", "g") 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 { function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○") 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(color.bold(color.white("Configuration Summary")))
lines.push("") 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 const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail)) lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
@@ -113,24 +125,34 @@ export function printBox(content: string, title?: string): void {
console.log() 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 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)") 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)`) 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)") 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)`) 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)") 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)`) 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)`) 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 } 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 { export function argsToConfig(args: InstallArgs): InstallConfig {
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
return { return {
hasClaude: args.claude !== "no", platform,
hasOpenCode,
hasClaude: hasOpenCode && args.claude !== "no",
isMax20: args.claude === "max20", isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes", hasOpenAI: hasOpenCode && args.openai === "yes",
hasGemini: args.gemini === "yes", hasGemini: hasOpenCode && args.gemini === "yes",
hasCopilot: args.copilot === "yes", hasCopilot: hasOpenCode && args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes", hasCodex,
hasZaiCodingPlan: args.zaiCodingPlan === "yes", hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
hasKimiForCoding: args.kimiForCoding === "yes", hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
hasOpencodeGo: args.opencodeGo === "yes", hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
hasVercelAiGateway: args.vercelAiGateway === "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 copilot: BooleanArg
opencodeZen: BooleanArg opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg kimiForCoding: BooleanArg
opencodeGo: BooleanArg opencodeGo: BooleanArg
vercelAiGateway: BooleanArg vercelAiGateway: BooleanArg
} { } {
@@ -199,7 +252,7 @@ kimiForCoding: BooleanArg
copilot: detected.hasCopilot ? "yes" : "no", copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no", opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no", zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
opencodeGo: detected.hasOpencodeGo ? "yes" : "no", opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no", vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
} }
+75 -1
View File
@@ -4,8 +4,10 @@ import type {
ClaudeSubscription, ClaudeSubscription,
DetectedConfig, DetectedConfig,
InstallConfig, InstallConfig,
InstallPlatform,
} from "./types" } from "./types"
import { detectedToInitialValues } from "./install-validators" import { detectedToInitialValues } from "./install-validators"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: { async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: {
message: string message: string
@@ -26,7 +28,58 @@ async function selectOrCancel<TValue extends Readonly<string | boolean | number>
return value as TValue 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 initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({ const claude = await selectOrCancel<ClaudeSubscription>({
@@ -121,15 +174,36 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
if (!vercelAiGateway) return null if (!vercelAiGateway) return null
return { return {
platform,
hasOpenCode: true,
hasClaude: claude !== "no", hasClaude: claude !== "no",
isMax20: claude === "max20", isMax20: claude === "max20",
hasOpenAI: openai === "yes", hasOpenAI: openai === "yes",
hasGemini: gemini === "yes", hasGemini: gemini === "yes",
hasCopilot: copilot === "yes", hasCopilot: copilot === "yes",
hasCodex,
hasOpencodeZen: opencodeZen === "yes", hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes", hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes", hasKimiForCoding: kimiForCoding === "yes",
hasOpencodeGo: opencodeGo === "yes", hasOpencodeGo: opencodeGo === "yes",
hasVercelAiGateway: vercelAiGateway === "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, start: () => undefined,
stop: () => undefined, stop: () => undefined,
message: () => undefined, message: () => undefined,
cancel: () => undefined,
error: () => undefined,
clear: () => undefined,
isCancelled: false,
} }
} }
describe("runTuiInstaller", () => { describe("runTuiInstaller", () => {
const originalIsStdinTty = process.stdin.isTTY const originalIsStdinTty = process.stdin.isTTY
const originalIsStdoutTty = process.stdout.isTTY const originalIsStdoutTty = process.stdout.isTTY
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => { beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
@@ -24,6 +29,11 @@ describe("runTuiInstaller", () => {
afterEach(() => { afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty }) Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty }) 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 () => { it("blocks installation when OpenCode is below the minimum version", async () => {
@@ -32,6 +42,7 @@ describe("runTuiInstaller", () => {
spyOn(p, "spinner").mockReturnValue(createMockSpinner()), spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined), spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined), spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false, isInstalled: false,
installedVersion: null, installedVersion: null,
@@ -40,11 +51,13 @@ describe("runTuiInstaller", () => {
hasOpenAI: false, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
hasOpencodeGo: false, hasOpencodeGo: false,
hasVercelAiGateway: false, hasVercelAiGateway: false,
codexAutonomous: false,
}), }),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"), spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
@@ -70,6 +83,29 @@ describe("runTuiInstaller", () => {
outroSpy.mockRestore() 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 () => { it("proceeds when OpenCode meets the minimum version", async () => {
// given // given
const restoreSpies = [ const restoreSpies = [
@@ -81,6 +117,7 @@ describe("runTuiInstaller", () => {
spyOn(p.log, "message").mockImplementation(() => undefined), spyOn(p.log, "message").mockImplementation(() => undefined),
spyOn(p, "note").mockImplementation(() => undefined), spyOn(p, "note").mockImplementation(() => undefined),
spyOn(p, "outro").mockImplementation(() => undefined), spyOn(p, "outro").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false, isInstalled: false,
installedVersion: null, installedVersion: null,
@@ -89,25 +126,31 @@ describe("runTuiInstaller", () => {
hasOpenAI: false, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
hasOpencodeGo: false, hasOpencodeGo: false,
hasVercelAiGateway: false, hasVercelAiGateway: false,
codexAutonomous: false,
}), }),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({ spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
platform: "opencode",
hasOpenCode: true,
hasClaude: false, hasClaude: false,
isMax20: false, isMax20: false,
hasOpenAI: false, hasOpenAI: false,
hasGemini: false, hasGemini: false,
hasCopilot: false, hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false, hasOpencodeZen: false,
hasZaiCodingPlan: false, hasZaiCodingPlan: false,
hasKimiForCoding: false, hasKimiForCoding: false,
hasOpencodeGo: false, hasOpencodeGo: false,
hasVercelAiGateway: false, hasVercelAiGateway: false,
codexAutonomous: false,
}), }),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true, success: true,
@@ -129,4 +172,60 @@ describe("runTuiInstaller", () => {
spy.mockRestore() 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" } from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" 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> { export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) { if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -19,8 +26,33 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
return 1 return 1
} }
const detected = detectCurrentConfig() const selectedPlatform = await promptInstallPlatform(args.platform ?? "opencode")
const isUpdate = detected.isInstalled 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... "))) 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() const spinner = p.spinner()
spinner.start("Checking OpenCode installation") if (hasOpenCode) {
spinner.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled() const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion() const openCodeVersion = await getOpenCodeVersion()
if (!installed) { if (!installed) {
spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`) 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.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") p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else { } else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`) spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) { if (unsupportedVersionMessage) {
p.log.warn(unsupportedVersionMessage) p.log.warn(unsupportedVersionMessage)
p.outro(color.red("Installation blocked.")) p.outro(color.red("Installation blocked."))
return 1 return 1
}
} }
} }
const config = await promptInstallConfig(detected) const config = await promptInstallConfig(detected, selectedPlatform, args.codexAutonomous)
if (!config) return 1 if (!config) return 1
spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`) if (config.hasOpenCode) {
const pluginResult = await addPluginToOpenCodeConfig(version) spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`)
if (!pluginResult.success) { const pluginResult = await addPluginToOpenCodeConfig(version)
spinner.stop(`Failed to add plugin: ${pluginResult.error}`) if (!pluginResult.success) {
p.outro(color.red("Installation failed.")) spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
return 1 p.outro(color.red("Installation failed."))
} return 1
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`) }
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
spinner.start(`Writing ${PLUGIN_NAME} configuration`) spinner.start(`Writing ${PLUGIN_NAME} configuration`)
const omoResult = writeOmoConfig(config) const omoResult = writeOmoConfig(config)
if (!omoResult.success) { if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`) spinner.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed.")) p.outro(color.red("Installation failed."))
return 1 return 1
}
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
} }
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) { if (!config.hasClaude) {
p.log.info( 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") 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.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("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") 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.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message( for (const repository of STAR_REPOSITORIES) {
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`, p.log.message(` ${color.dim(formatGitHubStarCommand(repository))}`)
) }
p.outro(color.green("oMoMoMoMo... Enjoy!")) 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[] = [] const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`) if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ Gemini")}`) 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 ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes" export type BooleanArg = "no" | "yes"
export type InstallPlatform = "opencode" | "codex" | "both"
export interface InstallArgs { export interface InstallArgs {
tui: boolean tui: boolean
platform?: InstallPlatform
claude?: ClaudeSubscription claude?: ClaudeSubscription
openai?: BooleanArg openai?: BooleanArg
gemini?: BooleanArg gemini?: BooleanArg
copilot?: BooleanArg copilot?: BooleanArg
opencodeZen?: BooleanArg opencodeZen?: BooleanArg
zaiCodingPlan?: BooleanArg zaiCodingPlan?: BooleanArg
kimiForCoding?: BooleanArg kimiForCoding?: BooleanArg
opencodeGo?: BooleanArg opencodeGo?: BooleanArg
vercelAiGateway?: BooleanArg vercelAiGateway?: BooleanArg
codexAutonomous?: boolean
skipAuth?: boolean skipAuth?: boolean
} }
export interface InstallConfig { export interface InstallConfig {
platform: InstallPlatform
hasOpenCode: boolean
hasClaude: boolean hasClaude: boolean
isMax20: boolean isMax20: boolean
hasOpenAI: boolean hasOpenAI: boolean
hasGemini: boolean hasGemini: boolean
hasCopilot: boolean hasCopilot: boolean
hasCodex: boolean
hasOpencodeZen: boolean hasOpencodeZen: boolean
hasZaiCodingPlan: boolean hasZaiCodingPlan: boolean
hasKimiForCoding: boolean hasKimiForCoding: boolean
hasOpencodeGo: boolean hasOpencodeGo: boolean
hasVercelAiGateway: boolean hasVercelAiGateway: boolean
codexAutonomous: boolean
} }
export interface ConfigMergeResult { export interface ConfigMergeResult {
@@ -42,6 +49,7 @@ export interface DetectedConfig {
hasOpenAI: boolean hasOpenAI: boolean
hasGemini: boolean hasGemini: boolean
hasCopilot: boolean hasCopilot: boolean
hasCodex: boolean
hasOpencodeZen: boolean hasOpencodeZen: boolean
hasZaiCodingPlan: boolean hasZaiCodingPlan: boolean
hasKimiForCoding: boolean hasKimiForCoding: boolean