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 ## 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 ### 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: 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 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 ### For LLM Agents
Fetch the installation guide and follow it: Fetch the full guide and follow it step by step:
```bash ```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) - [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. - If you're eligible for pay-per-token, using Kimi and Gemini models won't cost much.
| | Feature | What it does | | | Feature | Edition | What it does |
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🤖 | **Discipline Agents** | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. | | 🤖 | **Discipline Agents** | Ultimate | 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`. | | 🧩 | **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) | 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) | | 👥 | **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`** | One word. Every agent activates. Doesn't stop until done. | | ⚡ | **`ultrawork` / `ulw`** | Both | 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. | | 🚪 | **[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** | `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/) | | 🔗 | **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 + AST-Grep** | Workspace rename, pre-build diagnostics, AST-aware rewrites. IDE precision for agents. | | 🛠️ | **LSP integration** | Both | Diagnostics, navigation, symbols, workspace rename. IDE precision for agents. Same LSP MCP server in both editions. |
| 🧠 | **Background Agents** | Fire 5+ specialists in parallel. Context stays lean. Results when ready. | | 🔎 | **AST-Grep** | Ultimate | Pattern-aware code search and rewriting across 25 languages. |
| 📚 | **Built-in MCPs** | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. | | 🧠 | **Background Agents** | Ultimate | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
| 🔁 | **Ralph Loop / `/ulw-loop`** | Self-referential loop. Doesn't stop until 100% done. | | 📚 | **Built-in MCPs** (web/docs/code search) | Ultimate | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
| | **Todo Enforcer** | Agent goes idle? System yanks it back. Your task gets done, period. | | 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Self-referential loop. Doesn't stop until 100% done. |
| 💬 | **Comment Checker** | No AI slop in comments. Code reads like a senior wrote it. | | | **Todo Enforcer** | Ultimate | Agent goes idle? System yanks it back. Your task gets done, period. |
| 🖥️ | **Tmux Integration** | Full interactive terminal. REPLs, debuggers, TUIs. All live. | | 💬 | **Comment Checker** | Both | No AI slop in comments. Code reads like a senior wrote it. |
| 🔌 | **Claude Code Compatible** | Your hooks, commands, skills, MCPs, and plugins? All work here. | | 📐 | **Rules Injection** (`AGENTS.md` / `.omo/rules/**`) | Both | Project rules and AGENTS.md auto-loaded into the agent's context at every prompt. |
| 🎯 | **Skill-Embedded MCPs** | Skills carry their own MCP servers. No context bloat. | | 🎯 | **Ultragoal** | Both | Durable multi-goal orchestration with evidence audit, backed by `.omo/ultragoal/`. |
| 📋 | **Prometheus Planner** | Interview-mode strategic planning before any execution. | | 🖥️ | **Tmux Integration** | Ultimate | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
| 🔍 | **`/init-deep`** | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. | | 🔌 | **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 ### 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 { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager" import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import { runCliInstaller } from "./cli-installer" import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
@@ -47,6 +48,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
platform: "opencode",
claude: "no", claude: "no",
openai: "no", openai: "no",
gemini: "no", gemini: "no",
@@ -102,6 +104,7 @@ describe("runCliInstaller", () => {
const args: InstallArgs = { const args: InstallArgs = {
tui: false, tui: false,
platform: "opencode",
claude: "no", claude: "no",
openai: "yes", openai: "yes",
gemini: "no", gemini: "no",
@@ -122,4 +125,43 @@ describe("runCliInstaller", () => {
spy.mockRestore() 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 return 1
} }
const detected = detectCurrentConfig() const config = argsToConfig(args)
const isUpdate = detected.isInstalled const hasOpenCode = config.hasOpenCode
const detected = hasOpenCode
? detectCurrentConfig()
: {
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasCodex: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}
const isUpdate = hasOpenCode && detected.isInstalled
printHeader(isUpdate) printHeader(isUpdate)
const totalSteps = 4 const totalSteps = hasOpenCode ? 4 : 2
let step = 1 let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...") if (hasOpenCode) {
const installed = await isOpenCodeInstalled() printStep(step++, totalSteps, "Checking OpenCode installation...")
const openCodeVersion = await getOpenCodeVersion() const installed = await isOpenCodeInstalled()
if (!installed) { const openCodeVersion = await getOpenCodeVersion()
printWarning( if (!installed) {
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.", printWarning(
) "OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.",
printInfo("Visit https://opencode.ai/docs for installation instructions") )
} else { printInfo("Visit https://opencode.ai/docs for installation instructions")
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`) } else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) { if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage) printWarning(unsupportedVersionMessage)
return 1 return 1
}
} }
} }
@@ -72,25 +92,25 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`) printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
} }
const config = argsToConfig(args) if (hasOpenCode) {
printStep(step++, totalSteps, `Adding ${PLUGIN_NAME} plugin...`)
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
printStep(step++, totalSteps, `Adding ${PLUGIN_NAME} plugin...`) printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const pluginResult = await addPluginToOpenCodeConfig(version) const omoResult = writeOmoConfig(config)
if (!pluginResult.success) { if (!omoResult.success) {
printError(`Failed: ${pluginResult.error}`) printError(`Failed: ${omoResult.error}`)
return 1 return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
} }
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
printStep(step++, totalSteps, `Writing ${PLUGIN_NAME} configuration...`)
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
@@ -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(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`) if (hasOpenCode) {
console.log(` Run ${color.cyan("opencode")} to start!`)
}
console.log() console.log()
if (config.hasCodex) { 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)}`) printSuccess(`Codex plugin installed ${SYMBOLS.arrow} ${color.dim(codexResult.configPath)}`)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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}`) printWarning(`Codex install failed (OpenCode install is still complete): ${message}`)
} }
console.log() console.log()
@@ -149,7 +175,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
console.log(color.dim("oMoMoMoMo... Enjoy!")) console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log() console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { if (hasOpenCode && (config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox( printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` + `Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") + (config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
+7 -7
View File
@@ -1,4 +1,4 @@
import { Command } from "commander" import { Command, Option } from "commander"
import { install } from "./install" import { install } from "./install"
import { run } from "./run" import { run } from "./run"
import { getLocalVersion } from "./get-local-version" import { getLocalVersion } from "./get-local-version"
@@ -22,7 +22,7 @@ type InstallCommandOptions = {
readonly openai?: InstallArgs["openai"] readonly openai?: InstallArgs["openai"]
readonly gemini?: InstallArgs["gemini"] readonly gemini?: InstallArgs["gemini"]
readonly copilot?: InstallArgs["copilot"] readonly copilot?: InstallArgs["copilot"]
readonly codex?: InstallArgs["codex"] readonly platform?: InstallArgs["platform"]
readonly opencodeZen?: InstallArgs["opencodeZen"] readonly opencodeZen?: InstallArgs["opencodeZen"]
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"] readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
readonly kimiForCoding?: InstallArgs["kimiForCoding"] readonly kimiForCoding?: InstallArgs["kimiForCoding"]
@@ -41,7 +41,7 @@ export function resolveInstallArgs(
openai: options.openai, openai: options.openai,
gemini: options.gemini, gemini: options.gemini,
copilot: options.copilot, copilot: options.copilot,
codex: options.codex ?? (invocationName === "lazycodex" ? "yes" : undefined), platform: options.platform ?? (invocationName === "lazycodex" ? "codex" : undefined),
opencodeZen: options.opencodeZen, opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan, zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding, kimiForCoding: options.kimiForCoding,
@@ -67,7 +67,7 @@ program
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)") .option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes") .option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes") .option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.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("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)") .option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)") .option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
@@ -77,9 +77,9 @@ program
.addHelpText("after", ` .addHelpText("after", `
Examples: Examples:
$ bunx oh-my-opencode install $ bunx oh-my-opencode install
$ bunx lazycodex install --no-tui --claude=yes --gemini=no --copilot=no $ bunx lazycodex install --no-tui
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no --codex=yes $ bunx oh-my-opencode install --no-tui --platform=both --claude=max20 --openai=yes --gemini=yes --copilot=no
$ omo install --codex=yes $ omo install --platform=codex
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel): Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
+14 -10
View File
@@ -16,7 +16,7 @@ describe("lazycodex install routing", () => {
process.env.OMO_INVOCATION_NAME = originalInvocationName 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 // given
process.env.OMO_INVOCATION_NAME = "lazycodex" process.env.OMO_INVOCATION_NAME = "lazycodex"
@@ -30,11 +30,12 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args) const config = argsToConfig(args)
// then // then
expect(args.codex).toBe("yes") expect(args.platform).toBe("codex")
expect(config.hasCodex).toBe(true) 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 // given
process.env.OMO_INVOCATION_NAME = "lazycodex" process.env.OMO_INVOCATION_NAME = "lazycodex"
@@ -44,16 +45,17 @@ describe("lazycodex install routing", () => {
claude: "no", claude: "no",
gemini: "no", gemini: "no",
copilot: "no", copilot: "no",
codex: "no", platform: "both",
}) })
const config = argsToConfig(args) const config = argsToConfig(args)
// then // then
expect(args.codex).toBe("no") expect(args.platform).toBe("both")
expect(config.hasCodex).toBe(false) 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 // given
process.env.OMO_INVOCATION_NAME = "oh-my-opencode" process.env.OMO_INVOCATION_NAME = "oh-my-opencode"
@@ -67,11 +69,12 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args) const config = argsToConfig(args)
// then // then
expect(args.codex).toBeUndefined() expect(args.platform).toBeUndefined()
expect(config.hasCodex).toBe(false) 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 // given
delete process.env.OMO_INVOCATION_NAME delete process.env.OMO_INVOCATION_NAME
@@ -88,7 +91,8 @@ describe("lazycodex install routing", () => {
const config = argsToConfig(args) const config = argsToConfig(args)
// then // then
expect(args.codex).toBeUndefined() expect(args.platform).toBeUndefined()
expect(config.hasCodex).toBe(false) 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 { describe, expect, test } from "bun:test"
import { validateNonTuiArgs } from "./install-validators" import { argsToConfig, formatConfigSummary, validateNonTuiArgs } from "./install-validators"
import type { InstallArgs } from "./types" import type { InstallArgs } from "./types"
function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs { function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
@@ -10,16 +12,70 @@ function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
openai: "no", openai: "no",
gemini: "no", gemini: "no",
copilot: "no", copilot: "no",
codex: "no",
opencodeZen: "no", opencodeZen: "no",
zaiCodingPlan: "no", zaiCodingPlan: "no",
kimiForCoding: "no", kimiForCoding: "no",
opencodeGo: "no", opencodeGo: "no",
vercelAiGateway: "no",
skipAuth: false, skipAuth: false,
...overrides, ...overrides,
} }
} }
describe("argsToConfig", () => {
test("enables only OpenCode when platform is opencode", () => {
// #given
const args = createArgs({ platform: "opencode" })
// #when
const config = argsToConfig(args)
// #then
expect(config.platform).toBe("opencode")
expect(config.hasOpenCode).toBe(true)
expect(config.hasCodex).toBe(false)
})
test("enables only Codex when platform is codex", () => {
// #given
const args = createArgs({ platform: "codex" })
// #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", () => { describe("validateNonTuiArgs", () => {
test("rejects invalid --opencode-go values", () => { test("rejects invalid --opencode-go values", () => {
// #given // #given
@@ -32,4 +88,70 @@ describe("validateNonTuiArgs", () => {
expect(result.valid).toBe(false) expect(result.valid).toBe(false)
expect(result.errors).toContain("Invalid --opencode-go value: maybe (expected: no, yes)") expect(result.errors).toContain("Invalid --opencode-go value: maybe (expected: no, yes)")
}) })
test("requires OpenCode provider flags when platform is opencode", () => {
// #given
const args = createArgs({ platform: "opencode", claude: undefined, gemini: undefined, copilot: undefined })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain("--claude is required (values: no, yes, max20)")
expect(result.errors).toContain("--gemini is required (values: no, yes)")
expect(result.errors).toContain("--copilot is required (values: no, yes)")
})
test("requires OpenCode provider flags when platform is both", () => {
// #given
const args = createArgs({ platform: "both", claude: undefined, gemini: undefined, copilot: undefined })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain("--claude is required (values: no, yes, max20)")
expect(result.errors).toContain("--gemini is required (values: no, yes)")
expect(result.errors).toContain("--copilot is required (values: no, yes)")
})
test("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, DetectedConfig,
InstallArgs, InstallArgs,
InstallConfig, InstallConfig,
InstallPlatform,
} from "./types" } from "./types"
export const SYMBOLS = { export const SYMBOLS = {
@@ -31,13 +32,14 @@ export function formatConfigSummary(config: InstallConfig): string {
lines.push(color.bold(color.white("Configuration Summary"))) lines.push(color.bold(color.white("Configuration Summary")))
lines.push("") lines.push("")
lines.push(` ${SYMBOLS.info} Platform: ${config.platform}`)
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail)) lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle")) lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini)) lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback")) 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("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal")) lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback")) 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[] } { export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = [] const errors: string[] = []
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodexOnly = platform === "codex"
if (args.claude === undefined) { if (hasOpenCode && args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)") errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) { } else if (args.claude !== undefined && !["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`) errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
} }
if (args.gemini === undefined) { if (hasOpenCode && args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)") errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) { } else if (args.gemini !== undefined && !["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`) errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
} }
if (args.copilot === undefined) { if (hasOpenCode && args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)") errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) { } else if (args.copilot !== undefined && !["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`) errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
} }
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)) { if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`) 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)`) errors.push(`Invalid --vercel-ai-gateway value: ${args.vercelAiGateway} (expected: no, yes)`)
} }
if (hasCodexOnly) {
const opencodeFlagErrors = collectCodexOnlyOpenCodeFlagErrors(args)
errors.push(...opencodeFlagErrors)
}
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors }
} }
function resolvePlatform(args: InstallArgs): InstallPlatform {
return args.platform ?? "opencode"
}
function collectCodexOnlyOpenCodeFlagErrors(args: InstallArgs): string[] {
const errors: string[] = []
if (args.claude !== undefined) errors.push("--claude cannot be used with --platform=codex")
if (args.openai !== undefined) errors.push("--openai cannot be used with --platform=codex")
if (args.gemini !== undefined) errors.push("--gemini cannot be used with --platform=codex")
if (args.copilot !== undefined) errors.push("--copilot cannot be used with --platform=codex")
if (args.opencodeZen !== undefined) errors.push("--opencode-zen cannot be used with --platform=codex")
if (args.zaiCodingPlan !== undefined) errors.push("--zai-coding-plan cannot be used with --platform=codex")
if (args.kimiForCoding !== undefined) errors.push("--kimi-for-coding cannot be used with --platform=codex")
if (args.opencodeGo !== undefined) errors.push("--opencode-go cannot be used with --platform=codex")
if (args.vercelAiGateway !== undefined) errors.push("--vercel-ai-gateway cannot be used with --platform=codex")
return errors
}
export function argsToConfig(args: InstallArgs): InstallConfig { export function argsToConfig(args: InstallArgs): InstallConfig {
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
return { return {
hasClaude: args.claude !== "no", platform,
hasOpenCode,
hasClaude: hasOpenCode && args.claude !== "no",
isMax20: args.claude === "max20", isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes", hasOpenAI: hasOpenCode && args.openai === "yes",
hasGemini: args.gemini === "yes", hasGemini: hasOpenCode && args.gemini === "yes",
hasCopilot: args.copilot === "yes", hasCopilot: hasOpenCode && args.copilot === "yes",
hasCodex: args.codex === "yes", hasCodex,
hasOpencodeZen: args.opencodeZen === "yes", hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes", hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes", hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
hasOpencodeGo: args.opencodeGo === "yes", hasOpencodeGo: hasOpenCode && args.opencodeGo === "yes",
hasVercelAiGateway: args.vercelAiGateway === "yes", hasVercelAiGateway: hasOpenCode && args.vercelAiGateway === "yes",
} }
} }
@@ -187,7 +217,6 @@ export function detectedToInitialValues(detected: DetectedConfig): {
openai: BooleanArg openai: BooleanArg
gemini: BooleanArg gemini: BooleanArg
copilot: BooleanArg copilot: BooleanArg
codex: BooleanArg
opencodeZen: BooleanArg opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg kimiForCoding: BooleanArg
@@ -204,7 +233,6 @@ export function detectedToInitialValues(detected: DetectedConfig): {
openai: detected.hasOpenAI ? "yes" : "no", openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no", gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no", copilot: detected.hasCopilot ? "yes" : "no",
codex: detected.hasCodex ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no", opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no", zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
+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, ClaudeSubscription,
DetectedConfig, DetectedConfig,
InstallConfig, InstallConfig,
InstallPlatform,
} from "./types" } from "./types"
import { detectedToInitialValues } from "./install-validators" import { detectedToInitialValues } from "./install-validators"
@@ -26,7 +27,45 @@ async function selectOrCancel<TValue extends Readonly<string | boolean | number>
return value as TValue return value as TValue
} }
export async function promptInstallConfig(detected: DetectedConfig): Promise<InstallConfig | null> { export async function promptInstallPlatform(
initialValue: InstallPlatform = "opencode",
): 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 initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({ const claude = await selectOrCancel<ClaudeSubscription>({
@@ -70,16 +109,6 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
}) })
if (!copilot) return null 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({ const opencodeZen = await selectOrCancel({
message: "Do you have access to OpenCode Zen (opencode/ models)?", message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [ options: [
@@ -131,12 +160,14 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
if (!vercelAiGateway) return null if (!vercelAiGateway) return null
return { return {
platform,
hasOpenCode: true,
hasClaude: claude !== "no", hasClaude: claude !== "no",
isMax20: claude === "max20", isMax20: claude === "max20",
hasOpenAI: openai === "yes", hasOpenAI: openai === "yes",
hasGemini: gemini === "yes", hasGemini: gemini === "yes",
hasCopilot: copilot === "yes", hasCopilot: copilot === "yes",
hasCodex: codex === "yes", hasCodex,
hasOpencodeZen: opencodeZen === "yes", hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes", hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes", hasKimiForCoding: kimiForCoding === "yes",
+63
View File
@@ -9,6 +9,10 @@ function createMockSpinner(): ReturnType<typeof p.spinner> {
start: () => undefined, start: () => undefined,
stop: () => undefined, stop: () => undefined,
message: () => undefined, message: () => undefined,
cancel: () => undefined,
error: () => undefined,
clear: () => undefined,
isCancelled: false,
} }
} }
@@ -32,6 +36,7 @@ describe("runTuiInstaller", () => {
spyOn(p, "spinner").mockReturnValue(createMockSpinner()), spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined), spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined), spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false, isInstalled: false,
installedVersion: null, installedVersion: null,
@@ -82,6 +87,7 @@ describe("runTuiInstaller", () => {
spyOn(p.log, "message").mockImplementation(() => undefined), spyOn(p.log, "message").mockImplementation(() => undefined),
spyOn(p, "note").mockImplementation(() => undefined), spyOn(p, "note").mockImplementation(() => undefined),
spyOn(p, "outro").mockImplementation(() => undefined), spyOn(p, "outro").mockImplementation(() => undefined),
spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false, isInstalled: false,
installedVersion: null, installedVersion: null,
@@ -100,6 +106,8 @@ describe("runTuiInstaller", () => {
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({ spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
platform: "opencode",
hasOpenCode: true,
hasClaude: false, hasClaude: false,
isMax20: false, isMax20: false,
hasOpenAI: false, hasOpenAI: false,
@@ -132,4 +140,59 @@ describe("runTuiInstaller", () => {
spy.mockRestore() 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" } from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig } from "./tui-install-prompts" import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
import { runCodexInstaller } from "./install-codex" import { runCodexInstaller } from "./install-codex"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> { 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 return 1
} }
const detected = detectCurrentConfig() const selectedPlatform = await promptInstallPlatform(args.platform ?? "opencode")
const isUpdate = detected.isInstalled 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... "))) 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() const spinner = p.spinner()
spinner.start("Checking OpenCode installation") if (hasOpenCode) {
spinner.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled() const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion() const openCodeVersion = await getOpenCodeVersion()
if (!installed) { if (!installed) {
spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`) spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.") p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else { } else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`) spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) { if (unsupportedVersionMessage) {
p.log.warn(unsupportedVersionMessage) p.log.warn(unsupportedVersionMessage)
p.outro(color.red("Installation blocked.")) p.outro(color.red("Installation blocked."))
return 1 return 1
}
} }
} }
const config = await promptInstallConfig(detected) const config = await promptInstallConfig(detected, selectedPlatform)
if (!config) return 1 if (!config) return 1
spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`) if (config.hasOpenCode) {
const pluginResult = await addPluginToOpenCodeConfig(version) spinner.start(`Adding ${PLUGIN_NAME} to OpenCode config`)
if (!pluginResult.success) { const pluginResult = await addPluginToOpenCodeConfig(version)
spinner.stop(`Failed to add plugin: ${pluginResult.error}`) if (!pluginResult.success) {
p.outro(color.red("Installation failed.")) spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
return 1 p.outro(color.red("Installation failed."))
} return 1
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`) }
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
spinner.start(`Writing ${PLUGIN_NAME} configuration`) spinner.start(`Writing ${PLUGIN_NAME} configuration`)
const omoResult = writeOmoConfig(config) const omoResult = writeOmoConfig(config)
if (!omoResult.success) { if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`) spinner.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed.")) p.outro(color.red("Installation failed."))
return 1 return 1
}
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
} }
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) { if (!config.hasClaude) {
p.log.info( p.log.info(
@@ -92,12 +116,19 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
spinner.stop(`Codex install failed ${color.yellow("[!]")}`) 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.warn(`Codex install failed (OpenCode install remains successful): ${message}`)
} }
} }
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!")) p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`) if (config.hasOpenCode) {
p.log.message(`Run ${color.cyan("opencode")} to start!`)
}
p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.") p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.")
p.log.info("Docs: docs/legal/privacy-policy.md and docs/legal/terms-of-service.md") p.log.info("Docs: docs/legal/privacy-policy.md and docs/legal/terms-of-service.md")
@@ -115,7 +146,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
p.outro(color.green("oMoMoMoMo... Enjoy!")) p.outro(color.green("oMoMoMoMo... Enjoy!"))
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { if (config.hasOpenCode && (config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
const providers: string[] = [] const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`) if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ Gemini")}`) if (config.hasGemini) providers.push(`Google ${color.gray("→ Gemini")}`)
+4 -1
View File
@@ -1,13 +1,14 @@
export type ClaudeSubscription = "no" | "yes" | "max20" export type ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes" export type BooleanArg = "no" | "yes"
export type InstallPlatform = "opencode" | "codex" | "both"
export interface InstallArgs { export interface InstallArgs {
tui: boolean tui: boolean
platform?: InstallPlatform
claude?: ClaudeSubscription claude?: ClaudeSubscription
openai?: BooleanArg openai?: BooleanArg
gemini?: BooleanArg gemini?: BooleanArg
copilot?: BooleanArg copilot?: BooleanArg
codex?: BooleanArg
opencodeZen?: BooleanArg opencodeZen?: BooleanArg
zaiCodingPlan?: BooleanArg zaiCodingPlan?: BooleanArg
kimiForCoding?: BooleanArg kimiForCoding?: BooleanArg
@@ -17,6 +18,8 @@ export interface InstallArgs {
} }
export interface InstallConfig { export interface InstallConfig {
platform: InstallPlatform
hasOpenCode: boolean
hasClaude: boolean hasClaude: boolean
isMax20: boolean isMax20: boolean
hasOpenAI: boolean hasOpenAI: boolean
@@ -32,7 +32,7 @@ export function createToolContext(sessionID: string): ToolContext {
worktree: "/project", worktree: "/project",
abort: new AbortController().signal, abort: new AbortController().signal,
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
} }