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:
@@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:
|
|||||||
import { runCliInstaller } from "./cli-installer"
|
import { runCliInstaller } from "./cli-installer"
|
||||||
import * as configManager from "./config-manager"
|
import * as configManager from "./config-manager"
|
||||||
import * as codexInstaller from "./install-codex"
|
import * as codexInstaller from "./install-codex"
|
||||||
|
import * as claudeCodeInstaller from "./install-claudecode"
|
||||||
import type { CodexInstallResult } from "./install-codex"
|
import type { CodexInstallResult } from "./install-codex"
|
||||||
|
import type { ClaudeCodeInstallResult } from "./install-claudecode"
|
||||||
import type { InstallArgs } from "./types"
|
import type { InstallArgs } from "./types"
|
||||||
|
|
||||||
const codexResult: CodexInstallResult = {
|
const codexResult: CodexInstallResult = {
|
||||||
@@ -14,6 +16,11 @@ const codexResult: CodexInstallResult = {
|
|||||||
codexHome: "/tmp/codex-home",
|
codexHome: "/tmp/codex-home",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const claudeCodeResult: ClaudeCodeInstallResult = {
|
||||||
|
marketplaceName: "sisyphuslabs",
|
||||||
|
pluginRef: "omo@sisyphuslabs",
|
||||||
|
}
|
||||||
|
|
||||||
function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs {
|
function createOpenCodeArgs(platform: "opencode" | "both"): InstallArgs {
|
||||||
return {
|
return {
|
||||||
tui: false,
|
tui: false,
|
||||||
@@ -145,4 +152,37 @@ describe("runCliInstaller platform branching", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toBe(0)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "./install-validators"
|
} from "./install-validators"
|
||||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
import { runCodexInstaller } from "./install-codex"
|
import { runCodexInstaller } from "./install-codex"
|
||||||
|
import { runClaudeCodeInstaller } from "./install-claudecode"
|
||||||
|
|
||||||
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
const validation = validateNonTuiArgs(args)
|
const validation = validateNonTuiArgs(args)
|
||||||
@@ -154,6 +155,19 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
console.log()
|
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(
|
printInfo(
|
||||||
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
|
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-5
@@ -23,7 +23,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 platform?: InstallArgs["platform"]
|
readonly platform?: InstallPlatformOption
|
||||||
readonly opencodeZen?: InstallArgs["opencodeZen"]
|
readonly opencodeZen?: InstallArgs["opencodeZen"]
|
||||||
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
|
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
|
||||||
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
|
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
|
||||||
@@ -33,21 +33,34 @@ type InstallCommandOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Environment = Readonly<Record<string, string | undefined>>
|
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(
|
export function resolveInstallArgs(
|
||||||
options: InstallCommandOptions,
|
options: InstallCommandOptions,
|
||||||
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
|
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
|
||||||
env: Environment = process.env,
|
env: Environment = process.env,
|
||||||
): InstallArgs {
|
): InstallArgs {
|
||||||
const defaultPlatform = invocationName === "lazycodex" && isLazycodexPublishingEnabled(env) ? "codex" : undefined
|
const platform = normalizePlatform(options.platform)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tui: options.tui !== false,
|
tui: options.tui !== false,
|
||||||
claude: options.claude,
|
claude: options.claude,
|
||||||
openai: options.openai,
|
openai: options.openai,
|
||||||
gemini: options.gemini,
|
gemini: options.gemini,
|
||||||
copilot: options.copilot,
|
copilot: options.copilot,
|
||||||
platform: options.platform ?? defaultPlatform,
|
platform: platform ?? platformForInvocation(invocationName, env),
|
||||||
opencodeZen: options.opencodeZen,
|
opencodeZen: options.opencodeZen,
|
||||||
zaiCodingPlan: options.zaiCodingPlan,
|
zaiCodingPlan: options.zaiCodingPlan,
|
||||||
kimiForCoding: options.kimiForCoding,
|
kimiForCoding: options.kimiForCoding,
|
||||||
@@ -73,7 +86,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")
|
||||||
.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("--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)")
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { resolveInstallArgs } from "./cli-program"
|
import { resolveInstallArgs } from "./cli-program"
|
||||||
|
import type { InstallPlatform } from "./types"
|
||||||
|
|
||||||
describe("install platform resolution", () => {
|
describe("install platform resolution", () => {
|
||||||
test("leaves omo install without --platform unresolved for config defaults", () => {
|
test("leaves omo install without --platform unresolved for config defaults", () => {
|
||||||
@@ -92,6 +93,53 @@ describe("install platform resolution", () => {
|
|||||||
expect(args.platform).toBe("opencode")
|
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 () => {
|
test("defines Commander choices so invalid --platform values are rejected", async () => {
|
||||||
// given
|
// given
|
||||||
const cliProgramSource = await Bun.file(new URL("./cli-program.ts", import.meta.url)).text()
|
const cliProgramSource = await Bun.file(new URL("./cli-program.ts", import.meta.url)).text()
|
||||||
@@ -102,6 +150,6 @@ describe("install platform resolution", () => {
|
|||||||
// then
|
// then
|
||||||
expect(installBlock).not.toBeNull()
|
expect(installBlock).not.toBeNull()
|
||||||
expect(installBlock?.[1]).toContain('new Option("--platform <platform>"')
|
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"])')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function validateNonTuiArgs(
|
|||||||
const platform = resolvePlatform(args)
|
const platform = resolvePlatform(args)
|
||||||
const hasOpenCode = platform === "opencode" || platform === "both"
|
const hasOpenCode = platform === "opencode" || platform === "both"
|
||||||
const hasCodexOnly = platform === "codex"
|
const hasCodexOnly = platform === "codex"
|
||||||
|
const hasClaudeCodeOnly = platform === "claudecode"
|
||||||
|
|
||||||
if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) {
|
if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) {
|
||||||
errors.push(LAZYCODEX_DISABLED_MESSAGE)
|
errors.push(LAZYCODEX_DISABLED_MESSAGE)
|
||||||
@@ -182,6 +183,11 @@ export function validateNonTuiArgs(
|
|||||||
errors.push(...opencodeFlagErrors)
|
errors.push(...opencodeFlagErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasClaudeCodeOnly) {
|
||||||
|
const opencodeFlagErrors = collectClaudeCodeOnlyOpenCodeFlagErrors(args)
|
||||||
|
errors.push(...opencodeFlagErrors)
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,10 +209,25 @@ function collectCodexOnlyOpenCodeFlagErrors(args: InstallArgs): string[] {
|
|||||||
return errors
|
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 {
|
export function argsToConfig(args: InstallArgs): InstallConfig {
|
||||||
const platform = resolvePlatform(args)
|
const platform = resolvePlatform(args)
|
||||||
const hasOpenCode = platform === "opencode" || platform === "both"
|
const hasOpenCode = platform === "opencode" || platform === "both"
|
||||||
const hasCodex = platform === "codex" || platform === "both"
|
const hasCodex = platform === "codex" || platform === "both"
|
||||||
|
const hasClaudeCode = platform === "claudecode"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform,
|
platform,
|
||||||
@@ -217,6 +238,7 @@ export function argsToConfig(args: InstallArgs): InstallConfig {
|
|||||||
hasGemini: hasOpenCode && args.gemini === "yes",
|
hasGemini: hasOpenCode && args.gemini === "yes",
|
||||||
hasCopilot: hasOpenCode && args.copilot === "yes",
|
hasCopilot: hasOpenCode && args.copilot === "yes",
|
||||||
hasCodex,
|
hasCodex,
|
||||||
|
hasClaudeCode,
|
||||||
hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
|
hasOpencodeZen: hasOpenCode && args.opencodeZen === "yes",
|
||||||
hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
|
hasZaiCodingPlan: hasOpenCode && args.zaiCodingPlan === "yes",
|
||||||
hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
|
hasKimiForCoding: hasOpenCode && args.kimiForCoding === "yes",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function promptInstallPlatform(
|
|||||||
): Promise<InstallPlatform | null> {
|
): Promise<InstallPlatform | null> {
|
||||||
const options: Option<InstallPlatform>[] = [
|
const options: Option<InstallPlatform>[] = [
|
||||||
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
|
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
|
||||||
|
{ value: "claudecode", label: "Claude Code", hint: "Install the omo Claude Code plugin only" },
|
||||||
]
|
]
|
||||||
if (lazycodexEnabled) {
|
if (lazycodexEnabled) {
|
||||||
options.push(
|
options.push(
|
||||||
@@ -57,6 +58,7 @@ export async function promptInstallConfig(
|
|||||||
): Promise<InstallConfig | null> {
|
): Promise<InstallConfig | null> {
|
||||||
const hasOpenCode = platform === "opencode" || platform === "both"
|
const hasOpenCode = platform === "opencode" || platform === "both"
|
||||||
const hasCodex = platform === "codex" || platform === "both"
|
const hasCodex = platform === "codex" || platform === "both"
|
||||||
|
const hasClaudeCode = platform === "claudecode"
|
||||||
|
|
||||||
if (!hasOpenCode) {
|
if (!hasOpenCode) {
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +70,7 @@ export async function promptInstallConfig(
|
|||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
hasCodex,
|
hasCodex,
|
||||||
|
hasClaudeCode,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
@@ -178,6 +181,7 @@ export async function promptInstallConfig(
|
|||||||
hasGemini: gemini === "yes",
|
hasGemini: gemini === "yes",
|
||||||
hasCopilot: copilot === "yes",
|
hasCopilot: copilot === "yes",
|
||||||
hasCodex,
|
hasCodex,
|
||||||
|
hasClaudeCode,
|
||||||
hasOpencodeZen: opencodeZen === "yes",
|
hasOpencodeZen: opencodeZen === "yes",
|
||||||
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
||||||
hasKimiForCoding: kimiForCoding === "yes",
|
hasKimiForCoding: kimiForCoding === "yes",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install
|
|||||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
|
import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
|
||||||
import { runCodexInstaller } from "./install-codex"
|
import { runCodexInstaller } from "./install-codex"
|
||||||
|
import { runClaudeCodeInstaller } from "./install-claudecode"
|
||||||
import {
|
import {
|
||||||
LAZYCODEX_DISABLED_MESSAGE,
|
LAZYCODEX_DISABLED_MESSAGE,
|
||||||
isLazycodexPublishingEnabled,
|
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!"))
|
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
||||||
if (config.hasOpenCode) {
|
if (config.hasOpenCode) {
|
||||||
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
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 type InstallPlatform = "opencode" | "codex" | "claudecode" | "both"
|
||||||
|
|
||||||
export interface InstallArgs {
|
export interface InstallArgs {
|
||||||
tui: boolean
|
tui: boolean
|
||||||
@@ -26,6 +26,7 @@ export interface InstallConfig {
|
|||||||
hasGemini: boolean
|
hasGemini: boolean
|
||||||
hasCopilot: boolean
|
hasCopilot: boolean
|
||||||
hasCodex: boolean
|
hasCodex: boolean
|
||||||
|
hasClaudeCode: boolean
|
||||||
hasOpencodeZen: boolean
|
hasOpencodeZen: boolean
|
||||||
hasZaiCodingPlan: boolean
|
hasZaiCodingPlan: boolean
|
||||||
hasKimiForCoding: boolean
|
hasKimiForCoding: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user