This commit is contained in:
YeonGyu-Kim
2026-05-27 13:58:58 +09:00
parent 73e8988f31
commit db00bb3d42
18 changed files with 21606 additions and 431 deletions
+9
View File
@@ -0,0 +1,9 @@
{"permission":"read","pattern":"/Users/yeongyu/.agents/skills/ssh-jobdori-machine/SKILL.md","action":"allow"}
{"permission":"bash","pattern":"ls","action":"allow"}
{"permission":"bash","pattern":"ls *","action":"allow"}
{"permission":"bash","pattern":"ssh","action":"allow"}
{"permission":"bash","pattern":"ssh *","action":"allow"}
{"permission":"bash","pattern":"tailscale","action":"allow"}
{"permission":"bash","pattern":"tailscale *","action":"allow"}
{"permission":"bash","pattern":"scp","action":"allow"}
{"permission":"bash","pattern":"scp *","action":"allow"}
+69 -26
View File
@@ -101,30 +101,68 @@ Install oh-my-openagent. Type `ultrawork`. Done.
## Installation
oh-my-openagent ships in two editions of the same product:
- **Ultimate Edition (omo for OpenCode)** — full omo. 11 agents, 54+ lifecycle hooks, 5 built-in MCPs, all slash commands, Team Mode, ultragoal, ultrawork, hashline edits — everything.
- **Light Edition (omo for Codex CLI)** — the 5 components that port cleanly to Codex's plugin system: `rules`, `comment-checker`, `lsp`, `ultrawork`, `ultragoal`. No agent orchestration, no `team_*` tools, no built-in MCPs beyond LSP — Codex CLI's own surface does that work.
Pick the edition(s) you want.
### TL;DR
| You want | Run | What lands on disk |
| :--- | :--- | :--- |
| **Ultimate** (OpenCode) | `bunx omo install` (TUI walks you through it) | Plugin registered in `opencode.json` + agent/model config + provider auth prompts |
| **Light** (Codex CLI) | `bunx omo install --platform=codex` | `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/` + `~/.codex/config.toml` plugin block + `~/.local/bin/omo-*` |
| **Both** | `bunx omo install --platform=both` | Both of the above |
`--platform` defaults to `opencode` (Ultimate). The `bunx lazycodex install` alias is a one-letter-cheaper shortcut for `bunx omo install --platform=codex` — use whichever reads cleaner.
### For Humans
Copy and paste this prompt to your LLM agent (Claude Code, AmpCode, Cursor, etc.):
**Strongly recommended: let an LLM agent install this for you.** The Ultimate edition setup involves subscription detection, model selection across 11 agents, and per-provider authentication — humans fat-finger these. An LLM agent reads the full guide and walks every step correctly.
Paste this prompt into Claude Code, AmpCode, Cursor, or any agent:
```
Install and configure oh-my-openagent by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
```
Or read the [Installation Guide](docs/guide/installation.md), but seriously, let an agent do it. Humans fat-finger configs.
If you only want the **Light edition** (Codex CLI), the installer asks no questions and you can run it yourself in one line:
```bash
bunx omo install --platform=codex
```
> **Do not** use `npm install -g`, `bun add -g`, or `bun install -g`. Global installation is not officially supported — oh-my-openagent is a plugin that must resolve from where OpenCode/Codex loads plugins. Always invoke via `bunx`.
### For LLM Agents
Fetch the installation guide and follow it:
Fetch the full guide and follow it step by step:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
```
**Note**: The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the transition). Inside `opencode.json`, the compatibility layer now prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json` or `oh-my-opencode.jsonc`; both legacy and renamed basenames are recognized during the transition.
The guide covers: platform selection, the subscription interview, provider authentication (Anthropic / Gemini / Copilot / Z.ai / OpenCode Zen), the agent-to-model matching matrix, modes (`ultrawork`, `search`, `analyze`, `team`, `hyperplan`), slash commands, the Codex adapter's 5 components, Team Mode, and uninstall. Don't summarize it — read it end to end.
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). A single event is sent at most once per UTC day per machine using a hashed installation identifier, never the raw hostname, and PostHog person profiles are not created. Disable with `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`. See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
### Note on package and command names
If you also use OpenAI Codex CLI, add the optional Codex adapter with `bunx omo install --codex=yes` or the shortcut `bunx lazycodex install`. The Codex adapter emits `omo_codex_daily_active` at most once per UTC day per machine from two sources - the installer (`install_completed`) and the Codex plugin runtime on every session start (`session_start`) - using the same hashed installation identifier and PostHog person-profile suppression as the main plugin. Codex-only telemetry can be disabled with `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`.
The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the rename transition). Inside `opencode.json`, the compatibility layer prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json[c]`; both legacy and renamed basenames are recognized.
All four `bunx` aliases — `oh-my-opencode`, `oh-my-openagent`, `omo`, `lazycodex` — invoke the same compiled CLI. `omo` is the recommended short form for documentation and prompts. `lazycodex` is a single-purpose shortcut: `bunx lazycodex install` is exactly equivalent to `bunx omo install --platform=codex`.
### Telemetry
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). For both products, a single event is sent **at most once per UTC day per machine** using a SHA256-hashed installation identifier (never the raw hostname), and PostHog person profiles are not created. The main plugin emits `oh_my_openagent_daily_active`; the Codex adapter emits `omo_codex_daily_active` from two sources (`install_completed` and `session_start`).
Opt out per product:
- Main plugin: `OMO_DISABLE_POSTHOG=1` or `OMO_SEND_ANONYMOUS_TELEMETRY=0`
- Codex adapter: `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` (the global flags also disable Codex)
See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
---
@@ -159,25 +197,30 @@ Even with only the following subscriptions, `ultrawork` works well (this project
- [GLM Coding Plan ($10)](https://z.ai/subscribe)
- If you're eligible for pay-per-token, using Kimi and Gemini models won't cost much.
| | Feature | What it does |
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
| 🧩 | **Codex Harness Adapter** | Same OMO features for OpenAI Codex CLI. Install via `bunx lazycodex install` or `bunx omo install --codex=yes`. |
| 👥 | **Team Mode** (v4.0, opt-in) | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
| ⚡ | **`ultrawork` / `ulw`** | One word. Every agent activates. Doesn't stop until done. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Analyzes true user intent before classifying or acting. No more literal misinterpretations. |
| 🔗 | **Hash-Anchored Edit Tool** | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP + AST-Grep** | Workspace rename, pre-build diagnostics, AST-aware rewrites. IDE precision for agents. |
| 🧠 | **Background Agents** | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
| 📚 | **Built-in MCPs** | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Self-referential loop. Doesn't stop until 100% done. |
| | **Todo Enforcer** | Agent goes idle? System yanks it back. Your task gets done, period. |
| 💬 | **Comment Checker** | No AI slop in comments. Code reads like a senior wrote it. |
| 🖥️ | **Tmux Integration** | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
| 🔌 | **Claude Code Compatible** | Your hooks, commands, skills, MCPs, and plugins? All work here. |
| 🎯 | **Skill-Embedded MCPs** | Skills carry their own MCP servers. No context bloat. |
| 📋 | **Prometheus Planner** | Interview-mode strategic planning before any execution. |
| 🔍 | **`/init-deep`** | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
| | Feature | Edition | What it does |
| :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Ultimate | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
| 🧩 | **Codex CLI Light Edition** | Light | The 5 portable components of omo (rules, comment-checker, LSP, ultrawork, ultragoal) running inside OpenAI Codex CLI. Install via `bunx omo install --platform=codex`. |
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
| ⚡ | **`ultrawork` / `ulw`** | Both | One word. Every agent activates. Doesn't stop until done. |
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | Analyzes true user intent before classifying or acting. No more literal misinterpretations. (Light edition only recognises the `ultrawork`/`ulw` keyword.) |
| 🔗 | **Hash-Anchored Edit Tool** | Ultimate | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
| 🛠️ | **LSP integration** | Both | Diagnostics, navigation, symbols, workspace rename. IDE precision for agents. Same LSP MCP server in both editions. |
| 🔎 | **AST-Grep** | Ultimate | Pattern-aware code search and rewriting across 25 languages. |
| 🧠 | **Background Agents** | Ultimate | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
| 📚 | **Built-in MCPs** (web/docs/code search) | Ultimate | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Self-referential loop. Doesn't stop until 100% done. |
| | **Todo Enforcer** | Ultimate | Agent goes idle? System yanks it back. Your task gets done, period. |
| 💬 | **Comment Checker** | Both | No AI slop in comments. Code reads like a senior wrote it. |
| 📐 | **Rules Injection** (`AGENTS.md` / `.omo/rules/**`) | Both | Project rules and AGENTS.md auto-loaded into the agent's context at every prompt. |
| 🎯 | **Ultragoal** | Both | Durable multi-goal orchestration with evidence audit, backed by `.omo/ultragoal/`. |
| 🖥️ | **Tmux Integration** | Ultimate | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
| 🔌 | **Claude Code Compatible** | Ultimate | Your hooks, commands, skills, MCPs, and plugins? All work here. |
| 🧬 | **Skill-Embedded MCPs** | Ultimate | Skills carry their own MCP servers. No context bloat. |
| 📋 | **Prometheus Planner** | Ultimate | Interview-mode strategic planning before any execution. |
| 🔍 | **`/init-deep`** | Ultimate | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
> **Edition legend.** **Ultimate** = OpenCode-only (`bunx omo install`). **Light** = Codex CLI-only (`bunx omo install --platform=codex`). **Both** = shipped in both editions, often with slightly different implementations under the hood.
### Discipline Agents
+20166
View File
File diff suppressed because one or more lines are too long
+523 -278
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { runCliInstaller } from "./cli-installer"
import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import type { CodexInstallResult } from "./install-codex"
import type { InstallArgs } from "./types"
const codexResult: CodexInstallResult = {
marketplaceName: "code-yeongyu-codex-plugins",
installed: [],
configPath: "/tmp/codex-config.toml",
codexHome: "/tmp/codex-home",
}
function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs {
return {
tui: false,
platform,
claude: "no",
openai: "no",
gemini: "no",
copilot: "no",
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
vercelAiGateway: "no",
}
}
function stubOpenCodeSuccess(): void {
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
})
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
})
spyOn(configManager, "writeOmoConfig").mockReturnValue({
success: true,
configPath: "/tmp/oh-my-opencode.jsonc",
})
}
describe("runCliInstaller platform branching", () => {
const consoleLog = console.log
beforeEach(() => {
console.log = mock(() => {})
})
afterEach(() => {
console.log = consoleLog
mock.restore()
})
test("runs only OpenCode installation for platform=opencode", async () => {
// given
stubOpenCodeSuccess()
const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
const writeSpy = spyOn(configManager, "writeOmoConfig")
// when
const result = await runCliInstaller(createOpenCodeArgs("opencode"), "3.4.0")
// then
expect(result).toBe(0)
expect(writeSpy).toHaveBeenCalledTimes(1)
expect(codexSpy).not.toHaveBeenCalled()
})
test("runs only Codex installation and skips OpenCode version checks for platform=codex", async () => {
// given
const versionSpy = spyOn(configManager, "getOpenCodeVersion")
const writeSpy = spyOn(configManager, "writeOmoConfig")
const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
// when
const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0")
// then
expect(result).toBe(0)
expect(versionSpy).not.toHaveBeenCalled()
expect(writeSpy).not.toHaveBeenCalled()
expect(codexSpy).toHaveBeenCalledTimes(1)
})
test("runs OpenCode and Codex installation for platform=both", async () => {
// given
stubOpenCodeSuccess()
const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
const writeSpy = spyOn(configManager, "writeOmoConfig")
// when
const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0")
// then
expect(result).toBe(0)
expect(writeSpy).toHaveBeenCalledTimes(1)
expect(codexSpy).toHaveBeenCalledTimes(1)
})
test("fails when Codex-only installation cannot install Codex", async () => {
// given
spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed"))
// when
const result = await runCliInstaller({ tui: false, platform: "codex" }, "3.4.0")
// then
expect(result).toBe(1)
})
test("keeps OpenCode success when Codex fails for platform=both", async () => {
// given
stubOpenCodeSuccess()
spyOn(codexInstaller, "runCodexInstaller").mockRejectedValue(new Error("codex failed"))
// when
const result = await runCliInstaller(createOpenCodeArgs("both"), "3.4.0")
// then
expect(result).toBe(0)
})
})
+42
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types"
@@ -47,6 +48,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = {
tui: false,
platform: "opencode",
claude: "no",
openai: "no",
gemini: "no",
@@ -102,6 +104,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = {
tui: false,
platform: "opencode",
claude: "no",
openai: "yes",
gemini: "no",
@@ -122,4 +125,43 @@ describe("runCliInstaller", () => {
spy.mockRestore()
}
})
it("skips OpenCode checks and writes for platform=codex", async () => {
// given
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: "code-yeongyu-codex-plugins",
})
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()
})
})
+62 -36
View File
@@ -41,29 +41,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
}
}
}
@@ -72,25 +92,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")
@@ -113,7 +133,9 @@ 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) {
@@ -123,6 +145,10 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
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()
@@ -149,7 +175,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
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` : "") +
+7 -7
View File
@@ -1,4 +1,4 @@
import { Command } from "commander"
import { Command, Option } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
@@ -22,7 +22,7 @@ type InstallCommandOptions = {
readonly openai?: InstallArgs["openai"]
readonly gemini?: InstallArgs["gemini"]
readonly copilot?: InstallArgs["copilot"]
readonly codex?: InstallArgs["codex"]
readonly platform?: InstallArgs["platform"]
readonly opencodeZen?: InstallArgs["opencodeZen"]
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
@@ -41,7 +41,7 @@ export function resolveInstallArgs(
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
codex: options.codex ?? (invocationName === "lazycodex" ? "yes" : undefined),
platform: options.platform ?? (invocationName === "lazycodex" ? "codex" : undefined),
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
@@ -67,7 +67,7 @@ program
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--codex <value>", "Install Codex harness adapter: no, yes (default: no)")
.addOption(new Option("--platform <platform>", "Install target platform: opencode, codex, both").choices(["opencode", "codex", "both"]))
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
@@ -77,9 +77,9 @@ program
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx lazycodex install --no-tui --claude=yes --gemini=no --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no --codex=yes
$ omo install --codex=yes
$ 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
$ 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):
+14 -10
View File
@@ -16,7 +16,7 @@ describe("lazycodex install routing", () => {
process.env.OMO_INVOCATION_NAME = originalInvocationName
})
test("defaults codex to yes when invoked as lazycodex and user did not pass --codex", () => {
test("defaults platform to codex when invoked as lazycodex without --platform", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazycodex"
@@ -30,11 +30,12 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args)
// then
expect(args.codex).toBe("yes")
expect(args.platform).toBe("codex")
expect(config.hasCodex).toBe(true)
expect(config.hasOpenCode).toBe(false)
})
test("respects explicit --codex=no when invoked as lazycodex", () => {
test("respects explicit --platform=both when invoked as lazycodex", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazycodex"
@@ -44,16 +45,17 @@ describe("lazycodex install routing", () => {
claude: "no",
gemini: "no",
copilot: "no",
codex: "no",
platform: "both",
})
const config = argsToConfig(args)
// then
expect(args.codex).toBe("no")
expect(config.hasCodex).toBe(false)
expect(args.platform).toBe("both")
expect(config.hasCodex).toBe(true)
expect(config.hasOpenCode).toBe(true)
})
test("keeps codex disabled by default when invocation name is oh-my-opencode", () => {
test("leaves omo install unresolved so argsToConfig applies opencode default", () => {
// given
process.env.OMO_INVOCATION_NAME = "oh-my-opencode"
@@ -67,11 +69,12 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args)
// then
expect(args.codex).toBeUndefined()
expect(args.platform).toBeUndefined()
expect(config.hasCodex).toBe(false)
expect(config.hasOpenCode).toBe(true)
})
test("keeps codex disabled by default when invocation name is unset", () => {
test("leaves unset invocation unresolved so argsToConfig applies opencode default", () => {
// given
delete process.env.OMO_INVOCATION_NAME
@@ -88,7 +91,8 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args)
// then
expect(args.codex).toBeUndefined()
expect(args.platform).toBeUndefined()
expect(config.hasCodex).toBe(false)
expect(config.hasOpenCode).toBe(true)
})
})
@@ -0,0 +1,98 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { resolveInstallArgs } from "./cli-program"
describe("install platform resolution", () => {
test("leaves omo install without --platform unresolved for config defaults", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs({ tui: true }, invocationName)
// then
expect(args.platform).toBeUndefined()
})
test("resolves explicit --platform=codex", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs({ tui: true, platform: "codex" }, invocationName)
// then
expect(args.platform).toBe("codex")
})
test("resolves explicit --platform=both", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName)
// then
expect(args.platform).toBe("both")
})
test("resolves explicit --platform=opencode", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName)
// then
expect(args.platform).toBe("opencode")
})
test("defaults lazycodex install to codex platform", () => {
// given
const invocationName = "lazycodex"
// when
const args = resolveInstallArgs({ tui: true }, invocationName)
// then
expect(args.platform).toBe("codex")
})
test("lets lazycodex install explicitly override to both", () => {
// given
const invocationName = "lazycodex"
// when
const args = resolveInstallArgs({ tui: true, platform: "both" }, invocationName)
// then
expect(args.platform).toBe("both")
})
test("lets lazycodex install explicitly override to opencode", () => {
// given
const invocationName = "lazycodex"
// when
const args = resolveInstallArgs({ tui: true, platform: "opencode" }, invocationName)
// then
expect(args.platform).toBe("opencode")
})
test("defines Commander choices so invalid --platform values are rejected", async () => {
// given
const cliProgramSource = await readFile(path.resolve(import.meta.dir, "cli-program.ts"), "utf-8")
// when
const installBlock = cliProgramSource.match(/program\s*\n\s*\.command\("install"\)([\s\S]*?)\.action\(/)
// then
expect(installBlock).not.toBeNull()
expect(installBlock?.[1]).toContain('new Option("--platform <platform>"')
expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "both"])')
})
})
+124 -2
View File
@@ -1,6 +1,8 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { validateNonTuiArgs } from "./install-validators"
import { argsToConfig, formatConfigSummary, validateNonTuiArgs } from "./install-validators"
import type { InstallArgs } from "./types"
function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
@@ -10,16 +12,70 @@ function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
openai: "no",
gemini: "no",
copilot: "no",
codex: "no",
opencodeZen: "no",
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" })
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("codex")
expect(config.hasOpenCode).toBe(false)
expect(config.hasCodex).toBe(true)
})
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
@@ -32,4 +88,70 @@ 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("allows codex-only non-TUI installs without OpenCode provider flags", () => {
// #given
const args: InstallArgs = { tui: false, platform: "codex" }
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
test("rejects OpenCode flags for codex-only non-TUI installs", () => {
// #given
const args = createArgs({ platform: "codex", claude: "yes" })
// #when
const result = validateNonTuiArgs(args)
// #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")
})
})
+51 -23
View File
@@ -5,6 +5,7 @@ import type {
DetectedConfig,
InstallArgs,
InstallConfig,
InstallPlatform,
} from "./types"
export const SYMBOLS = {
@@ -31,13 +32,14 @@ export function formatConfigSummary(config: InstallConfig): string {
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
lines.push(` ${SYMBOLS.info} Platform: ${config.platform}`)
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("Codex Harness (oh-my-codex plugin)", config.hasCodex))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
@@ -116,29 +118,28 @@ export function printBox(content: string, title?: string): void {
export function validateNonTuiArgs(args: InstallArgs): { 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 (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)`)
}
if (args.codex !== undefined && !["no", "yes"].includes(args.codex)) {
errors.push(`Invalid --codex value: ${args.codex} (expected: no, yes)`)
}
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
}
@@ -163,22 +164,51 @@ 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",
hasCodex: args.codex === "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",
}
}
@@ -187,7 +217,6 @@ export function detectedToInitialValues(detected: DetectedConfig): {
openai: BooleanArg
gemini: BooleanArg
copilot: BooleanArg
codex: BooleanArg
opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg
@@ -204,7 +233,6 @@ export function detectedToInitialValues(detected: DetectedConfig): {
openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no",
codex: detected.hasCodex ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
+123
View File
@@ -0,0 +1,123 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import * as p from "@clack/prompts"
import * as prompts from "./tui-install-prompts"
import type { DetectedConfig, InstallConfig, InstallPlatform } from "./types"
function createDetectedConfig(): DetectedConfig {
return {
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}
}
function withTty(): () => void {
const originalIsStdinTty = process.stdin.isTTY
const originalIsStdoutTty = process.stdout.isTTY
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true })
return () => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
}
}
describe("promptInstallPlatform", () => {
let restoreTty: () => void
beforeEach(() => {
restoreTty = withTty()
})
afterEach(() => {
restoreTty()
mock.restore()
})
test("offers OpenCode, Codex, and Both choices with OpenCode as the default", async () => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("opencode")
// when
const value = await prompts.promptInstallPlatform()
// then
expect(value).toBe("opencode")
expect(selectSpy).toHaveBeenCalledTimes(1)
expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({
initialValue: "opencode",
options: [
{ value: "opencode" },
{ value: "codex" },
{ value: "both" },
],
})
})
})
describe("promptInstallConfig platform branching", () => {
let restoreTty: () => void
beforeEach(() => {
restoreTty = withTty()
})
afterEach(() => {
restoreTty()
mock.restore()
})
test("skips OpenCode questions when the user selects codex", async () => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("no")
// when
const config = await prompts.promptInstallConfig(createDetectedConfig(), "codex")
// then
expect(config).toMatchObject({ platform: "codex", hasOpenCode: false, hasCodex: true } satisfies Partial<InstallConfig>)
expect(selectSpy).not.toHaveBeenCalled()
})
test.each([
["opencode", false],
["both", true],
] satisfies readonly [InstallPlatform, boolean][])(
"asks OpenCode questions when the user selects %s",
async (platform, hasCodex) => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("no")
// when
const config = await prompts.promptInstallConfig(createDetectedConfig(), platform)
// then
expect(config).toMatchObject({ platform, hasOpenCode: true, hasCodex } satisfies Partial<InstallConfig>)
expect(selectSpy).toHaveBeenCalledTimes(9)
},
)
test("does not ask the old Codex adapter question", async () => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("no")
// when
await prompts.promptInstallConfig(createDetectedConfig(), "both")
// then
const messages = selectSpy.mock.calls.map((call) => call[0].message)
expect(messages).not.toContain("Install Codex harness adapter into ~/.codex?")
})
})
+43 -12
View File
@@ -4,6 +4,7 @@ import type {
ClaudeSubscription,
DetectedConfig,
InstallConfig,
InstallPlatform,
} from "./types"
import { detectedToInitialValues } from "./install-validators"
@@ -26,7 +27,45 @@ async function selectOrCancel<TValue extends Readonly<string | boolean | number>
return value as TValue
}
export async function promptInstallConfig(detected: DetectedConfig): Promise<InstallConfig | null> {
export async function promptInstallPlatform(
initialValue: InstallPlatform = "opencode",
): Promise<InstallPlatform | null> {
return selectOrCancel<InstallPlatform>({
message: "Which platform do you want to install?",
options: [
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
{ value: "codex", label: "Codex", hint: "Install Codex harness adapter only" },
{ value: "both", label: "Both", hint: "Install OpenCode plugin and Codex adapter" },
],
initialValue,
})
}
export async function promptInstallConfig(
detected: DetectedConfig,
platform: InstallPlatform,
): Promise<InstallConfig | null> {
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
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,
}
}
const initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({
@@ -70,16 +109,6 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
})
if (!copilot) return null
const codex = await selectOrCancel({
message: "Install Codex harness adapter into ~/.codex?",
options: [
{ value: "no", label: "No", hint: "Skip Codex plugin installation" },
{ value: "yes", label: "Yes", hint: "Install vendored oh-my-codex plugin" },
],
initialValue: initial.codex,
})
if (!codex) return null
const opencodeZen = await selectOrCancel({
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
@@ -131,12 +160,14 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
if (!vercelAiGateway) return null
return {
platform,
hasOpenCode: true,
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasCodex: codex === "yes",
hasCodex,
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
+63
View File
@@ -9,6 +9,10 @@ function createMockSpinner(): ReturnType<typeof p.spinner> {
start: () => undefined,
stop: () => undefined,
message: () => undefined,
cancel: () => undefined,
error: () => undefined,
clear: () => undefined,
isCancelled: false,
}
}
@@ -32,6 +36,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,
@@ -82,6 +87,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,
@@ -100,6 +106,8 @@ describe("runTuiInstaller", () => {
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,
@@ -132,4 +140,59 @@ describe("runTuiInstaller", () => {
spy.mockRestore()
}
})
it("skips OpenCode checks and writes when platform is codex", async () => {
// given
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()
})
})
+66 -35
View File
@@ -11,7 +11,7 @@ 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"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
@@ -20,8 +20,28 @@ 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
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... ")))
@@ -31,45 +51,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)
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(
@@ -92,12 +116,19 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
} 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")
@@ -115,7 +146,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
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")}`)
+4 -1
View File
@@ -1,13 +1,14 @@
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
codex?: BooleanArg
opencodeZen?: BooleanArg
zaiCodingPlan?: BooleanArg
kimiForCoding?: BooleanArg
@@ -17,6 +18,8 @@ export interface InstallArgs {
}
export interface InstallConfig {
platform: InstallPlatform
hasOpenCode: boolean
hasClaude: boolean
isMax20: boolean
hasOpenAI: boolean
@@ -32,7 +32,7 @@ export function createToolContext(sessionID: string): ToolContext {
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
ask: () => Effect.void,
}
}