From 7c9b70fd8e251061d6a164a19b42a9e63456a23e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:14 +0900 Subject: [PATCH] test(cli): batch 82 (10 files) --- src/cli/cli-installer.telemetry.test.ts | 1 + src/cli/cli-installer.test.ts | 51 ++++++++ src/cli/cli-installer.ts | 118 +++++++++++------ src/cli/cli-program.ts | 68 +++++++--- src/cli/install-validators.test.ts | 166 +++++++++++++++++++++++- src/cli/install-validators.ts | 89 ++++++++++--- src/cli/tui-install-prompts.ts | 76 ++++++++++- src/cli/tui-installer.test.ts | 99 ++++++++++++++ src/cli/tui-installer.ts | 131 +++++++++++++------ src/cli/types.ts | 10 +- 10 files changed, 695 insertions(+), 114 deletions(-) diff --git a/src/cli/cli-installer.telemetry.test.ts b/src/cli/cli-installer.telemetry.test.ts index 772173910..5a7927d10 100644 --- a/src/cli/cli-installer.telemetry.test.ts +++ b/src/cli/cli-installer.telemetry.test.ts @@ -18,6 +18,7 @@ describe("runCliInstaller telemetry isolation", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 3fbb21f20..6f71f3ca0 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -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() + }) }) diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 029215757..a8f0aaf1b 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -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 { 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` : "") + diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 96519bd48..81d8458c8 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -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> + +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 ", "OpenAI/ChatGPT subscription: no, yes (default: no)") .option("--gemini ", "Gemini integration: no, yes") .option("--copilot ", "GitHub Copilot subscription: no, yes") + .addOption(new Option("--platform ", "Install target platform: opencode, codex, both").choices(["opencode", "codex", "both"])) .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") .option("--opencode-go ", "OpenCode Go subscription: no, yes (default: no)") .option("--vercel-ai-gateway ", "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) }) diff --git a/src/cli/install-validators.test.ts b/src/cli/install-validators.test.ts index 119f21ab8..4ef7fc26a 100644 --- a/src/cli/install-validators.test.ts +++ b/src/cli/install-validators.test.ts @@ -1,6 +1,8 @@ +/// + 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 { @@ -14,11 +16,79 @@ function createArgs(overrides: Partial = {}): 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") + }) }) diff --git a/src/cli/install-validators.ts b/src/cli/install-validators.ts index 695747edf..dd2437062 100644 --- a/src/cli/install-validators.ts +++ b/src/cli/install-validators.ts @@ -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> 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", } diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index cec155688..ca2c72648 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -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>(params: { message: string @@ -26,7 +28,58 @@ async function selectOrCancel return value as TValue } -export async function promptInstallConfig(detected: DetectedConfig): Promise { +export async function promptInstallPlatform( + initialValue: InstallPlatform = "opencode", + lazycodexEnabled = isLazycodexPublishingEnabled(), +): Promise { + const options: Option[] = [ + { 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({ + message: "Which platform do you want to install?", + options, + initialValue: safeInitialValue, + }) +} + +export async function promptInstallConfig( + detected: DetectedConfig, + platform: InstallPlatform, + codexAutonomousOverride?: boolean, +): Promise { + 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({ @@ -121,15 +174,36 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise { + if (!hasCodex) return false + if (override !== undefined) return override + + return selectOrCancel({ + 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, + }) +} diff --git a/src/cli/tui-installer.test.ts b/src/cli/tui-installer.test.ts index 0e5b1344d..873b33611 100644 --- a/src/cli/tui-installer.test.ts +++ b/src/cli/tui-installer.test.ts @@ -9,12 +9,17 @@ function createMockSpinner(): ReturnType { 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() + }) }) diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index ef4b9088a..93609525f 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -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 { 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")}`) diff --git a/src/cli/types.ts b/src/cli/types.ts index c61f96bd4..5ff838332 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -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