feat(cli): add claudecode install platform + runner + lazyclaudecode routing

--platform=claudecode (alias cc normalized in resolveInstallArgs); hasClaudeCode;
runClaudeCodeInstaller shells to claude plugin marketplace add/install, prints
/plugin commands + errors (no ~/.claude write) when claude absent; install
telemetry via @oh-my-opencode/omo-claude/telemetry; lazyclaudecode invocation
routes to claudecode. 26 target tests + 23 dependent tests green; both unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
YeonGyu-Kim
2026-05-29 13:50:17 +09:00
parent c6bfc14268
commit 79ec1c23c1
14 changed files with 431 additions and 7 deletions
+40
View File
@@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:
import { runCliInstaller } from "./cli-installer"
import * as configManager from "./config-manager"
import * as codexInstaller from "./install-codex"
import * as claudeCodeInstaller from "./install-claudecode"
import type { CodexInstallResult } from "./install-codex"
import type { ClaudeCodeInstallResult } from "./install-claudecode"
import type { InstallArgs } from "./types"
const codexResult: CodexInstallResult = {
@@ -14,6 +16,11 @@ const codexResult: CodexInstallResult = {
codexHome: "/tmp/codex-home",
}
const claudeCodeResult: ClaudeCodeInstallResult = {
marketplaceName: "sisyphuslabs",
pluginRef: "omo@sisyphuslabs",
}
function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs {
return {
tui: false,
@@ -145,4 +152,37 @@ describe("runCliInstaller platform branching", () => {
// then
expect(result).toBe(0)
})
test("runs only Claude Code installation and skips OpenCode/Codex for platform=claudecode", async () => {
// given
const versionSpy = spyOn(configManager, "getOpenCodeVersion")
const writeSpy = spyOn(configManager, "writeOmoConfig")
const codexSpy = spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult)
const claudeCodeSpy = spyOn(claudeCodeInstaller, "runClaudeCodeInstaller").mockResolvedValue(
claudeCodeResult,
)
// when
const result = await runCliInstaller({ tui: false, platform: "claudecode" }, "3.4.0")
// then
expect(result).toBe(0)
expect(versionSpy).not.toHaveBeenCalled()
expect(writeSpy).not.toHaveBeenCalled()
expect(codexSpy).not.toHaveBeenCalled()
expect(claudeCodeSpy).toHaveBeenCalledTimes(1)
})
test("fails when Claude-Code-only installation cannot install the plugin", async () => {
// given
spyOn(claudeCodeInstaller, "runClaudeCodeInstaller").mockRejectedValue(
new Error("claude code failed"),
)
// when
const result = await runCliInstaller({ tui: false, platform: "claudecode" }, "3.4.0")
// then
expect(result).toBe(1)
})
})
+14
View File
@@ -24,6 +24,7 @@ import {
} from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { runCodexInstaller } from "./install-codex"
import { runClaudeCodeInstaller } from "./install-claudecode"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args)
@@ -154,6 +155,19 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
console.log()
}
if (config.hasClaudeCode) {
printInfo("Installing Claude Code plugin...")
try {
const claudeResult = await runClaudeCodeInstaller()
printSuccess(`Claude Code plugin installed ${SYMBOLS.arrow} ${color.dim(claudeResult.pluginRef)}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
printError(`Claude Code install failed: ${message}`)
return 1
}
console.log()
}
printInfo(
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
)
+18 -5
View File
@@ -23,7 +23,7 @@ type InstallCommandOptions = {
readonly openai?: InstallArgs["openai"]
readonly gemini?: InstallArgs["gemini"]
readonly copilot?: InstallArgs["copilot"]
readonly platform?: InstallArgs["platform"]
readonly platform?: InstallPlatformOption
readonly opencodeZen?: InstallArgs["opencodeZen"]
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
@@ -33,21 +33,34 @@ type InstallCommandOptions = {
}
type Environment = Readonly<Record<string, string | undefined>>
type InstallPlatformOption = InstallArgs["platform"] | "cc"
function normalizePlatform(platform: InstallPlatformOption | undefined): InstallArgs["platform"] {
return platform === "cc" ? "claudecode" : platform
}
function platformForInvocation(
invocationName: string | undefined,
env: Environment,
): InstallArgs["platform"] {
if (invocationName === "lazycodex" && isLazycodexPublishingEnabled(env)) return "codex"
if (invocationName === "lazyclaudecode") return "claudecode"
return undefined
}
export function resolveInstallArgs(
options: InstallCommandOptions,
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
env: Environment = process.env,
): InstallArgs {
const defaultPlatform = invocationName === "lazycodex" && isLazycodexPublishingEnabled(env) ? "codex" : undefined
const platform = normalizePlatform(options.platform)
return {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
platform: options.platform ?? defaultPlatform,
platform: platform ?? platformForInvocation(invocationName, env),
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
@@ -73,7 +86,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")
.addOption(new Option("--platform <platform>", "Install target platform: opencode, codex, both").choices(["opencode", "codex", "both"]))
.addOption(new Option("--platform <platform>", "Install target platform: opencode, codex, claudecode (alias: cc), both").choices(["opencode", "codex", "claudecode", "cc", "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)")
@@ -0,0 +1,17 @@
import { spawn } from "../../shared/bun-spawn-shim"
import type { RunCommand } from "./types"
export const defaultRunCommand: RunCommand = async (command, args, options) => {
const proc = spawn({
cmd: [command, ...args],
env: options.env as NodeJS.ProcessEnv | undefined,
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
})
const code = await proc.exited
if (code !== 0) {
throw new Error(`${command} ${args.join(" ")} failed with exit code ${code}`)
}
}
+3
View File
@@ -0,0 +1,3 @@
export type { ClaudeCodeInstallOptions, ClaudeCodeInstallResult, RunCommand } from "./types"
export { runClaudeCodeInstaller } from "./install-claudecode"
export { defaultRunCommand } from "./claudecode-process"
@@ -0,0 +1,95 @@
/// <reference path="../../../bun-test.d.ts" />
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { existsSync } from "node:fs"
import { mkdtemp, readdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { runClaudeCodeInstaller } from "./install-claudecode"
describe("runClaudeCodeInstaller", () => {
test("shells out to claude plugin marketplace add then plugin install", async () => {
// given
const calls: { command: string; args: readonly string[] }[] = []
const runCommand = async (command: string, args: readonly string[]) => {
calls.push({ command, args })
}
// when
const result = await runClaudeCodeInstaller({ runCommand })
// then
expect(calls.length).toBe(2)
expect(calls[0]?.command).toBe("claude")
expect(calls[0]?.args).toEqual(["plugin", "marketplace", "add", "code-yeongyu/lazyclaudecode"])
expect(calls[1]?.command).toBe("claude")
expect(calls[1]?.args).toEqual(["plugin", "install", "omo@sisyphuslabs"])
expect(result.marketplaceName).toBe("sisyphuslabs")
expect(result.pluginRef).toBe("omo@sisyphuslabs")
})
test("prints the two /plugin commands and throws when claude is absent, writing nothing to ~/.claude", async () => {
// given: an isolated HOME so we can assert ~/.claude is never written
const fakeHome = await mkdtemp(join(tmpdir(), "omo-cc-home-"))
// a PATH that contains no `claude` binary
const emptyBinDir = await mkdtemp(join(tmpdir(), "omo-cc-emptybin-"))
const printed: string[] = []
const claudeHome = join(fakeHome, ".claude")
// when / then
let thrown: unknown
try {
await runClaudeCodeInstaller({
homeDir: fakeHome,
env: { PATH: emptyBinDir, HOME: fakeHome },
log: (message) => printed.push(message),
})
} catch (error) {
thrown = error
}
// then: it threw a clear error
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message.toLowerCase()).toContain("claude")
// then: it printed BOTH /plugin commands for manual recovery
const printedText = printed.join("\n")
expect(printedText).toContain("/plugin marketplace add code-yeongyu/lazyclaudecode")
expect(printedText).toContain("/plugin install omo@sisyphuslabs")
// then: NOTHING was written to ~/.claude
expect(existsSync(claudeHome)).toBe(false)
const entries = await readdir(fakeHome)
expect(entries).toEqual([])
})
test("prints the two /plugin commands and throws when claude exits non-zero, writing nothing to ~/.claude", async () => {
// given
const fakeHome = await mkdtemp(join(tmpdir(), "omo-cc-home-"))
const printed: string[] = []
const claudeHome = join(fakeHome, ".claude")
const runCommand = async () => {
throw new Error("claude plugin marketplace add code-yeongyu/lazyclaudecode failed with exit code 1")
}
// when / then
let thrown: unknown
try {
await runClaudeCodeInstaller({
homeDir: fakeHome,
runCommand,
log: (message) => printed.push(message),
})
} catch (error) {
thrown = error
}
// then
expect(thrown).toBeInstanceOf(Error)
const printedText = printed.join("\n")
expect(printedText).toContain("/plugin marketplace add code-yeongyu/lazyclaudecode")
expect(printedText).toContain("/plugin install omo@sisyphuslabs")
expect(existsSync(claudeHome)).toBe(false)
})
})
@@ -0,0 +1,63 @@
import { homedir } from "node:os"
import { defaultRunCommand } from "./claudecode-process"
import type { ClaudeCodeInstallOptions, ClaudeCodeInstallResult } from "./types"
const MARKETPLACE_NAME = "sisyphuslabs"
const DEFAULT_MARKETPLACE_REPO = "code-yeongyu/lazyclaudecode"
const DEFAULT_PLUGIN_REF = "omo@sisyphuslabs"
export async function runClaudeCodeInstaller(
options: ClaudeCodeInstallOptions = {},
): Promise<ClaudeCodeInstallResult> {
const marketplaceRepo = options.marketplaceRepo ?? DEFAULT_MARKETPLACE_REPO
const pluginRef = options.pluginRef ?? DEFAULT_PLUGIN_REF
const runCommand = options.runCommand ?? defaultRunCommand
const log = options.log ?? ((message: string) => console.log(message))
// homedir is resolved (never written to) so the caller can prove ~/.claude stays untouched.
void (options.homeDir ?? homedir())
const env = options.env ?? process.env
const marketplaceAddArgs = ["plugin", "marketplace", "add", marketplaceRepo] as const
const pluginInstallArgs = ["plugin", "install", pluginRef] as const
try {
await runCommand("claude", marketplaceAddArgs, { env })
await runCommand("claude", pluginInstallArgs, { env })
} catch (error) {
printManualInstructions(log, marketplaceRepo, pluginRef)
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to install the Claude Code plugin via the 'claude' CLI (${reason}). ` +
"Install Claude Code first (https://claude.com/claude-code), then run the two /plugin commands printed above.",
)
}
await trackClaudeCodeInstallTelemetry()
return {
marketplaceName: MARKETPLACE_NAME,
pluginRef,
}
}
function printManualInstructions(
log: (message: string) => void,
marketplaceRepo: string,
pluginRef: string,
): void {
log("Could not drive the 'claude' CLI. Run these inside Claude Code to install manually:")
log(` /plugin marketplace add ${marketplaceRepo}`)
log(` /plugin install ${pluginRef}`)
}
async function trackClaudeCodeInstallTelemetry(): Promise<void> {
try {
const { createInstallPostHog, getPostHogDistinctId } = await import("@oh-my-opencode/omo-claude/telemetry")
const posthog = createInstallPostHog()
posthog.trackActive(getPostHogDistinctId(), "install_completed")
await posthog.shutdown()
} catch {
// no-excuse-ok: catch
// telemetry must never break installs
}
}
@@ -0,0 +1,66 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test"
import process from "node:process"
import { resolveInstallArgs } from "../cli-program"
import { argsToConfig } from "../install-validators"
describe("lazyclaudecode install routing", () => {
const originalInvocationName = process.env.OMO_INVOCATION_NAME
afterEach(() => {
if (originalInvocationName === undefined) {
delete process.env.OMO_INVOCATION_NAME
return
}
process.env.OMO_INVOCATION_NAME = originalInvocationName
})
test("defaults platform to claudecode when invoked as lazyclaudecode without --platform", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazyclaudecode"
// when
const args = resolveInstallArgs({ tui: false })
const config = argsToConfig(args)
// then
expect(args.platform).toBe("claudecode")
expect(config.hasClaudeCode).toBe(true)
expect(config.hasOpenCode).toBe(false)
expect(config.hasCodex).toBe(false)
})
test("respects explicit --platform=opencode when invoked as lazyclaudecode", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazyclaudecode"
// when
const args = resolveInstallArgs({
tui: false,
claude: "no",
gemini: "no",
copilot: "no",
platform: "opencode",
})
const config = argsToConfig(args)
// then
expect(args.platform).toBe("opencode")
expect(config.hasClaudeCode).toBe(false)
expect(config.hasOpenCode).toBe(true)
})
test("does not resolve lazyclaudecode to codex or opencode by default", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazyclaudecode"
// when
const args = resolveInstallArgs({ tui: false })
// then
expect(args.platform).not.toBe("codex")
expect(args.platform).not.toBe("opencode")
expect(args.platform).toBe("claudecode")
})
})
+23
View File
@@ -0,0 +1,23 @@
export interface CommandRunOptions {
readonly env?: { readonly [key: string]: string | undefined }
}
export type RunCommand = (
command: string,
args: readonly string[],
options: CommandRunOptions,
) => Promise<void>
export interface ClaudeCodeInstallOptions {
readonly marketplaceRepo?: string
readonly pluginRef?: string
readonly runCommand?: RunCommand
readonly log?: (message: string) => void
readonly homeDir?: string
readonly env?: { readonly [key: string]: string | undefined }
}
export interface ClaudeCodeInstallResult {
readonly marketplaceName: string
readonly pluginRef: string
}
+49 -1
View File
@@ -2,6 +2,7 @@
import { describe, expect, test } from "bun:test"
import { resolveInstallArgs } from "./cli-program"
import type { InstallPlatform } from "./types"
describe("install platform resolution", () => {
test("leaves omo install without --platform unresolved for config defaults", () => {
@@ -92,6 +93,53 @@ describe("install platform resolution", () => {
expect(args.platform).toBe("opencode")
})
test("resolves explicit --platform=claudecode", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs({ tui: true, platform: "claudecode" }, invocationName)
// then
expect(args.platform).toBe("claudecode")
})
test("normalizes --platform=cc to claudecode", () => {
// given
const invocationName = "omo"
// when
const args = resolveInstallArgs(
{ tui: true, platform: "cc" as InstallPlatform },
invocationName,
)
// then
expect(args.platform).toBe("claudecode")
})
test("defaults lazyclaudecode install to claudecode platform", () => {
// given
const invocationName = "lazyclaudecode"
// when
const args = resolveInstallArgs({ tui: true }, invocationName)
// then
expect(args.platform).toBe("claudecode")
})
test("lets lazyclaudecode install explicitly override to opencode", () => {
// given
const invocationName = "lazyclaudecode"
// 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 Bun.file(new URL("./cli-program.ts", import.meta.url)).text()
@@ -102,6 +150,6 @@ describe("install platform resolution", () => {
// then
expect(installBlock).not.toBeNull()
expect(installBlock?.[1]).toContain('new Option("--platform <platform>"')
expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "both"])')
expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "claudecode", "cc", "both"])')
})
})
+22
View File
@@ -130,6 +130,7 @@ export function validateNonTuiArgs(
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodexOnly = platform === "codex"
const hasClaudeCodeOnly = platform === "claudecode"
if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) {
errors.push(LAZYCODEX_DISABLED_MESSAGE)
@@ -182,6 +183,11 @@ export function validateNonTuiArgs(
errors.push(...opencodeFlagErrors)
}
if (hasClaudeCodeOnly) {
const opencodeFlagErrors = collectClaudeCodeOnlyOpenCodeFlagErrors(args)
errors.push(...opencodeFlagErrors)
}
return { valid: errors.length === 0, errors }
}
@@ -203,10 +209,25 @@ function collectCodexOnlyOpenCodeFlagErrors(args: InstallArgs): string[] {
return errors
}
function collectClaudeCodeOnlyOpenCodeFlagErrors(args: InstallArgs): string[] {
const errors: string[] = []
if (args.claude !== undefined) errors.push("--claude cannot be used with --platform=claudecode")
if (args.openai !== undefined) errors.push("--openai cannot be used with --platform=claudecode")
if (args.gemini !== undefined) errors.push("--gemini cannot be used with --platform=claudecode")
if (args.copilot !== undefined) errors.push("--copilot cannot be used with --platform=claudecode")
if (args.opencodeZen !== undefined) errors.push("--opencode-zen cannot be used with --platform=claudecode")
if (args.zaiCodingPlan !== undefined) errors.push("--zai-coding-plan cannot be used with --platform=claudecode")
if (args.kimiForCoding !== undefined) errors.push("--kimi-for-coding cannot be used with --platform=claudecode")
if (args.opencodeGo !== undefined) errors.push("--opencode-go cannot be used with --platform=claudecode")
if (args.vercelAiGateway !== undefined) errors.push("--vercel-ai-gateway cannot be used with --platform=claudecode")
return errors
}
export function argsToConfig(args: InstallArgs): InstallConfig {
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
const hasClaudeCode = platform === "claudecode"
return {
platform,
@@ -217,6 +238,7 @@ export function argsToConfig(args: InstallArgs): InstallConfig {
hasGemini: hasOpenCode && args.gemini === "yes",
hasCopilot: hasOpenCode && args.copilot === "yes",
hasCodex,
hasClaudeCode,
hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
+4
View File
@@ -34,6 +34,7 @@ export async function promptInstallPlatform(
): Promise<InstallPlatform | null> {
const options: Option<InstallPlatform>[] = [
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
{ value: "claudecode", label: "Claude Code", hint: "Install the omo Claude Code plugin only" },
]
if (lazycodexEnabled) {
options.push(
@@ -57,6 +58,7 @@ export async function promptInstallConfig(
): Promise<InstallConfig | null> {
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodex = platform === "codex" || platform === "both"
const hasClaudeCode = platform === "claudecode"
if (!hasOpenCode) {
return {
@@ -68,6 +70,7 @@ export async function promptInstallConfig(
hasGemini: false,
hasCopilot: false,
hasCodex,
hasClaudeCode,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
@@ -178,6 +181,7 @@ export async function promptInstallConfig(
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasCodex,
hasClaudeCode,
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
+15
View File
@@ -13,6 +13,7 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
import { runCodexInstaller } from "./install-codex"
import { runClaudeCodeInstaller } from "./install-claudecode"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
@@ -135,6 +136,20 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
}
}
if (config.hasClaudeCode) {
spinner.start("Installing Claude Code plugin")
try {
const claudeResult = await runClaudeCodeInstaller()
spinner.stop(`Claude Code plugin installed (${color.cyan(claudeResult.pluginRef)})`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
spinner.stop(`Claude Code install failed ${color.yellow("[!]")}`)
p.log.error(`Claude Code install failed: ${message}`)
p.outro(color.red("Installation failed."))
return 1
}
}
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
if (config.hasOpenCode) {
p.log.message(`Run ${color.cyan("opencode")} to start!`)
+2 -1
View File
@@ -1,6 +1,6 @@
export type ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes"
export type InstallPlatform = "opencode" | "codex" | "both"
export type InstallPlatform = "opencode" | "codex" | "claudecode" | "both"
export interface InstallArgs {
tui: boolean
@@ -26,6 +26,7 @@ export interface InstallConfig {
hasGemini: boolean
hasCopilot: boolean
hasCodex: boolean
hasClaudeCode: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean