From 79ec1c23c1fb8fe3a3caa71c0e18cb01ec7c3f19 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 13:50:17 +0900 Subject: [PATCH] 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) --- src/cli/cli-installer.platform.test.ts | 40 ++++++++ src/cli/cli-installer.ts | 14 +++ src/cli/cli-program.ts | 23 ++++- .../install-claudecode/claudecode-process.ts | 17 ++++ src/cli/install-claudecode/index.ts | 3 + .../install-claudecode.test.ts | 95 +++++++++++++++++++ .../install-claudecode/install-claudecode.ts | 63 ++++++++++++ .../lazyclaudecode-routing.test.ts | 66 +++++++++++++ src/cli/install-claudecode/types.ts | 23 +++++ src/cli/install-platform-resolution.test.ts | 50 +++++++++- src/cli/install-validators.ts | 22 +++++ src/cli/tui-install-prompts.ts | 4 + src/cli/tui-installer.ts | 15 +++ src/cli/types.ts | 3 +- 14 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 src/cli/install-claudecode/claudecode-process.ts create mode 100644 src/cli/install-claudecode/index.ts create mode 100644 src/cli/install-claudecode/install-claudecode.test.ts create mode 100644 src/cli/install-claudecode/install-claudecode.ts create mode 100644 src/cli/install-claudecode/lazyclaudecode-routing.test.ts create mode 100644 src/cli/install-claudecode/types.ts diff --git a/src/cli/cli-installer.platform.test.ts b/src/cli/cli-installer.platform.test.ts index 126ed268d..da8d71eec 100644 --- a/src/cli/cli-installer.platform.test.ts +++ b/src/cli/cli-installer.platform.test.ts @@ -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) + }) }) diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 880ece349..4ffc305e2 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -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 { 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.", ) diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 61ae60d7f..894351ef8 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -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> +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 ", "OpenAI/ChatGPT subscription: no, yes (default: no)") .option("--gemini ", "Gemini integration: no, yes") .option("--copilot ", "GitHub Copilot subscription: no, yes") - .addOption(new Option("--platform ", "Install target platform: opencode, codex, both").choices(["opencode", "codex", "both"])) + .addOption(new Option("--platform ", "Install target platform: opencode, codex, claudecode (alias: cc), both").choices(["opencode", "codex", "claudecode", "cc", "both"])) .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") diff --git a/src/cli/install-claudecode/claudecode-process.ts b/src/cli/install-claudecode/claudecode-process.ts new file mode 100644 index 000000000..79cb8b0e9 --- /dev/null +++ b/src/cli/install-claudecode/claudecode-process.ts @@ -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}`) + } +} diff --git a/src/cli/install-claudecode/index.ts b/src/cli/install-claudecode/index.ts new file mode 100644 index 000000000..e6d0306f8 --- /dev/null +++ b/src/cli/install-claudecode/index.ts @@ -0,0 +1,3 @@ +export type { ClaudeCodeInstallOptions, ClaudeCodeInstallResult, RunCommand } from "./types" +export { runClaudeCodeInstaller } from "./install-claudecode" +export { defaultRunCommand } from "./claudecode-process" diff --git a/src/cli/install-claudecode/install-claudecode.test.ts b/src/cli/install-claudecode/install-claudecode.test.ts new file mode 100644 index 000000000..49d0c690d --- /dev/null +++ b/src/cli/install-claudecode/install-claudecode.test.ts @@ -0,0 +1,95 @@ +/// +/// + +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) + }) +}) diff --git a/src/cli/install-claudecode/install-claudecode.ts b/src/cli/install-claudecode/install-claudecode.ts new file mode 100644 index 000000000..851838017 --- /dev/null +++ b/src/cli/install-claudecode/install-claudecode.ts @@ -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 { + 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 { + 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 + } +} diff --git a/src/cli/install-claudecode/lazyclaudecode-routing.test.ts b/src/cli/install-claudecode/lazyclaudecode-routing.test.ts new file mode 100644 index 000000000..c2fde9344 --- /dev/null +++ b/src/cli/install-claudecode/lazyclaudecode-routing.test.ts @@ -0,0 +1,66 @@ +/// + +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") + }) +}) diff --git a/src/cli/install-claudecode/types.ts b/src/cli/install-claudecode/types.ts new file mode 100644 index 000000000..e4f9fbfec --- /dev/null +++ b/src/cli/install-claudecode/types.ts @@ -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 + +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 +} diff --git a/src/cli/install-platform-resolution.test.ts b/src/cli/install-platform-resolution.test.ts index cad0125ba..ee7a8f802 100644 --- a/src/cli/install-platform-resolution.test.ts +++ b/src/cli/install-platform-resolution.test.ts @@ -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 "') - expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "both"])') + expect(installBlock?.[1]).toContain('.choices(["opencode", "codex", "claudecode", "cc", "both"])') }) }) diff --git a/src/cli/install-validators.ts b/src/cli/install-validators.ts index f82179bf2..5339712e4 100644 --- a/src/cli/install-validators.ts +++ b/src/cli/install-validators.ts @@ -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", diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index 84104bc29..a270ee209 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -34,6 +34,7 @@ export async function promptInstallPlatform( ): Promise { const options: Option[] = [ { 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 { 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", diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index d3c37d6a7..7ca12a2e4 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -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!`) diff --git a/src/cli/types.ts b/src/cli/types.ts index 1891c492a..8ca242cc8 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -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